tokenmax-collector 0.1.0 → 0.2.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/package.json +1 -1
- package/src/antigravity.ts +181 -0
- package/src/ccusage.ts +14 -5
- package/src/cli.ts +2 -1
- package/src/collect.ts +51 -18
- package/src/http.ts +6 -0
- package/src/mapping.ts +56 -1
- package/src/paths.ts +2 -0
- package/src/pricing.ts +108 -0
package/package.json
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
interface ProtoField {
|
|
15
|
+
number: number;
|
|
16
|
+
value: bigint | Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RawStep {
|
|
20
|
+
at: Date;
|
|
21
|
+
cacheRead: number;
|
|
22
|
+
input: number;
|
|
23
|
+
modelCode: number;
|
|
24
|
+
output: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const unknownModel = "gemini-unknown";
|
|
28
|
+
|
|
29
|
+
const usageField = 9;
|
|
30
|
+
const createdField = 1;
|
|
31
|
+
const generationField = 1;
|
|
32
|
+
const generationUsageField = 4;
|
|
33
|
+
const generationModelField = 19;
|
|
34
|
+
const modelCodeField = 1;
|
|
35
|
+
const inputField = 2;
|
|
36
|
+
const outputField = 3;
|
|
37
|
+
const cacheReadField = 5;
|
|
38
|
+
|
|
39
|
+
export function antigravityConversationsDir(home: string): string {
|
|
40
|
+
return resolve(home, ".gemini", "antigravity-cli", "conversations");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodeMessage(bytes: Uint8Array): ProtoField[] {
|
|
44
|
+
const fields: ProtoField[] = [];
|
|
45
|
+
let offset = 0;
|
|
46
|
+
const varint = (): bigint => {
|
|
47
|
+
let result = 0n;
|
|
48
|
+
let shift = 0n;
|
|
49
|
+
for (;;) {
|
|
50
|
+
const byte = bytes[offset++];
|
|
51
|
+
result |= BigInt(byte & 0x7f) << shift;
|
|
52
|
+
if (byte < 0x80) {
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
shift += 7n;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const bytesOf = (length: number): Uint8Array => {
|
|
59
|
+
const slice = bytes.subarray(offset, offset + length);
|
|
60
|
+
offset += length;
|
|
61
|
+
return slice;
|
|
62
|
+
};
|
|
63
|
+
while (offset < bytes.length) {
|
|
64
|
+
const tag = varint();
|
|
65
|
+
const number = Number(tag >> 3n);
|
|
66
|
+
const wireType = Number(tag & 7n);
|
|
67
|
+
if (wireType === 0) {
|
|
68
|
+
fields.push({ number, value: varint() });
|
|
69
|
+
} else if (wireType === 1) {
|
|
70
|
+
fields.push({ number, value: bytesOf(8) });
|
|
71
|
+
} else if (wireType === 2) {
|
|
72
|
+
fields.push({ number, value: bytesOf(Number(varint())) });
|
|
73
|
+
} else if (wireType === 5) {
|
|
74
|
+
fields.push({ number, value: bytesOf(4) });
|
|
75
|
+
} else {
|
|
76
|
+
throw new Error(`unsupported protobuf wire type ${wireType}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return fields;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nested(fields: ProtoField[], number: number): ProtoField[] | null {
|
|
83
|
+
const field = fields.find(
|
|
84
|
+
(candidate) =>
|
|
85
|
+
candidate.number === number && candidate.value instanceof Uint8Array,
|
|
86
|
+
);
|
|
87
|
+
return field === undefined ? null : decodeMessage(field.value as Uint8Array);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function integer(fields: ProtoField[], number: number): number | null {
|
|
91
|
+
const field = fields.find(
|
|
92
|
+
(candidate) =>
|
|
93
|
+
candidate.number === number && typeof candidate.value === "bigint",
|
|
94
|
+
);
|
|
95
|
+
return field === undefined ? null : Number(field.value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function text(fields: ProtoField[], number: number): string | null {
|
|
99
|
+
const field = fields.find(
|
|
100
|
+
(candidate) =>
|
|
101
|
+
candidate.number === number && candidate.value instanceof Uint8Array,
|
|
102
|
+
);
|
|
103
|
+
return field === undefined
|
|
104
|
+
? null
|
|
105
|
+
: new TextDecoder().decode(field.value as Uint8Array);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function openReadOnly(file: string): DatabaseSync {
|
|
109
|
+
const location = existsSync(`${file}-wal`)
|
|
110
|
+
? file
|
|
111
|
+
: `file:${file}?immutable=1`;
|
|
112
|
+
return new DatabaseSync(location, { readOnly: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readConversation(
|
|
116
|
+
file: string,
|
|
117
|
+
names: Map<number, string>,
|
|
118
|
+
steps: RawStep[],
|
|
119
|
+
): void {
|
|
120
|
+
const db = openReadOnly(file);
|
|
121
|
+
try {
|
|
122
|
+
const generations = db.prepare("SELECT data FROM gen_metadata").all() as {
|
|
123
|
+
data: Uint8Array;
|
|
124
|
+
}[];
|
|
125
|
+
for (const row of generations) {
|
|
126
|
+
const generation = nested(decodeMessage(row.data), generationField);
|
|
127
|
+
const usage =
|
|
128
|
+
generation === null ? null : nested(generation, generationUsageField);
|
|
129
|
+
const code = usage === null ? null : integer(usage, modelCodeField);
|
|
130
|
+
const name =
|
|
131
|
+
generation === null ? null : text(generation, generationModelField);
|
|
132
|
+
if (code !== null && name !== null) {
|
|
133
|
+
names.set(code, name);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const rows = db
|
|
137
|
+
.prepare("SELECT metadata FROM steps WHERE metadata IS NOT NULL")
|
|
138
|
+
.all() as { metadata: Uint8Array }[];
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
const fields = decodeMessage(row.metadata);
|
|
141
|
+
const usage = nested(fields, usageField);
|
|
142
|
+
const created = nested(fields, createdField);
|
|
143
|
+
const seconds = created === null ? null : integer(created, 1);
|
|
144
|
+
if (usage === null || seconds === null) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
steps.push({
|
|
148
|
+
at: new Date(seconds * 1000),
|
|
149
|
+
cacheRead: integer(usage, cacheReadField) ?? 0,
|
|
150
|
+
input: integer(usage, inputField) ?? 0,
|
|
151
|
+
modelCode: integer(usage, modelCodeField) ?? 0,
|
|
152
|
+
output: integer(usage, outputField) ?? 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
db.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function readAntigravitySteps(
|
|
161
|
+
dir: string,
|
|
162
|
+
): Promise<AntigravityStep[]> {
|
|
163
|
+
let files: string[];
|
|
164
|
+
try {
|
|
165
|
+
files = await readdir(dir);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
const names = new Map<number, string>();
|
|
173
|
+
const steps: RawStep[] = [];
|
|
174
|
+
for (const file of files.filter((name) => name.endsWith(".db")).sort()) {
|
|
175
|
+
readConversation(resolve(dir, file), names, steps);
|
|
176
|
+
}
|
|
177
|
+
return steps.map(({ modelCode, ...step }) => ({
|
|
178
|
+
...step,
|
|
179
|
+
model: names.get(modelCode) ?? unknownModel,
|
|
180
|
+
}));
|
|
181
|
+
}
|
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";
|
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;
|
|
@@ -83,8 +87,27 @@ async function report(
|
|
|
83
87
|
return { accepted, kind: "reported", machine: usage.machine };
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
async function antigravityDays(
|
|
91
|
+
home: string,
|
|
92
|
+
today: Date,
|
|
93
|
+
timezone: string,
|
|
94
|
+
fetcher: Fetcher,
|
|
95
|
+
pricesFile: string,
|
|
96
|
+
): Promise<UsageDay[]> {
|
|
97
|
+
const since = windowStart(today, timezone);
|
|
98
|
+
const steps = (
|
|
99
|
+
await readAntigravitySteps(antigravityConversationsDir(home))
|
|
100
|
+
).filter((step) => calendarDate(step.at, timezone) >= since);
|
|
101
|
+
if (steps.length === 0) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const prices = await loadPrices(fetcher, pricesFile, today);
|
|
105
|
+
return mapAntigravitySteps(steps, timezone, prices);
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
export async function collect(options: CollectOptions): Promise<CollectResult> {
|
|
87
|
-
const
|
|
109
|
+
const env = options.env ?? processEnv();
|
|
110
|
+
const paths = collectorPaths(env);
|
|
88
111
|
const config = await readConfig(paths.configFile);
|
|
89
112
|
if (config.kind === "missing") {
|
|
90
113
|
return { configFile: paths.configFile, kind: "missing-config" };
|
|
@@ -100,15 +123,26 @@ export async function collect(options: CollectOptions): Promise<CollectResult> {
|
|
|
100
123
|
const timezone =
|
|
101
124
|
config.config.timezone ?? options.timezone ?? runtimeTimezone();
|
|
102
125
|
const runner = options.runner ?? runCommand;
|
|
126
|
+
const fetcher = options.fetcher ?? fetch;
|
|
127
|
+
const today = options.today ?? new Date();
|
|
103
128
|
|
|
104
129
|
let days: UsageDay[];
|
|
105
130
|
try {
|
|
106
131
|
const daily = await readCcusageDaily(
|
|
107
132
|
runner,
|
|
108
|
-
sinceArgument(
|
|
133
|
+
sinceArgument(today, timezone),
|
|
109
134
|
timezone,
|
|
110
135
|
);
|
|
111
|
-
days =
|
|
136
|
+
days = [
|
|
137
|
+
...mapCcusageDays(daily),
|
|
138
|
+
...(await antigravityDays(
|
|
139
|
+
env.home,
|
|
140
|
+
today,
|
|
141
|
+
timezone,
|
|
142
|
+
fetcher,
|
|
143
|
+
paths.pricesFile,
|
|
144
|
+
)),
|
|
145
|
+
];
|
|
112
146
|
} catch (error) {
|
|
113
147
|
return { kind: "failed", message: messageOf(error) };
|
|
114
148
|
}
|
|
@@ -117,12 +151,11 @@ export async function collect(options: CollectOptions): Promise<CollectResult> {
|
|
|
117
151
|
}
|
|
118
152
|
|
|
119
153
|
try {
|
|
120
|
-
return await report(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
);
|
|
154
|
+
return await report(fetcher, config.config.url, config.config.key, {
|
|
155
|
+
days,
|
|
156
|
+
machine,
|
|
157
|
+
timezone,
|
|
158
|
+
});
|
|
126
159
|
} catch (error) {
|
|
127
160
|
return { kind: "failed", message: messageOf(error) };
|
|
128
161
|
}
|
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,108 @@
|
|
|
1
|
+
import { mkdir, readFile, 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; source: string } | null> {
|
|
61
|
+
try {
|
|
62
|
+
const [source, info] = await Promise.all([
|
|
63
|
+
readFile(pricesFile, "utf8"),
|
|
64
|
+
stat(pricesFile),
|
|
65
|
+
]);
|
|
66
|
+
return { modifiedAt: info.mtimeMs, source };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function fetchPrices(fetcher: Fetcher): Promise<string> {
|
|
76
|
+
const response = await fetcher(litellmPricesUrl, { method: "GET" });
|
|
77
|
+
const source = await response.text();
|
|
78
|
+
if (response.status !== 200) {
|
|
79
|
+
throw new Error(`LiteLLM responded ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
return source;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loadPrices(
|
|
85
|
+
fetcher: Fetcher,
|
|
86
|
+
pricesFile: string,
|
|
87
|
+
now: Date,
|
|
88
|
+
): Promise<PriceTable> {
|
|
89
|
+
const cached = await readCache(pricesFile);
|
|
90
|
+
if (cached !== null && now.getTime() - cached.modifiedAt < maxAgeMs) {
|
|
91
|
+
return parseLitellmPrices(cached.source);
|
|
92
|
+
}
|
|
93
|
+
let source: string;
|
|
94
|
+
let table: PriceTable;
|
|
95
|
+
try {
|
|
96
|
+
source = await fetchPrices(fetcher);
|
|
97
|
+
table = parseLitellmPrices(source);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (cached !== null) {
|
|
100
|
+
return parseLitellmPrices(cached.source);
|
|
101
|
+
}
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
throw new Error(`could not load model prices: ${message}`);
|
|
104
|
+
}
|
|
105
|
+
await mkdir(dirname(pricesFile), { recursive: true });
|
|
106
|
+
await writeFile(pricesFile, source);
|
|
107
|
+
return table;
|
|
108
|
+
}
|