tokenmax-collector 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmax-collector",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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,13 +36,26 @@ 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");
45
- return 0;
39
+ if (result.kind === "reported" || result.kind === "empty") {
40
+ for (const warning of result.warnings) {
41
+ io.stderr(warning);
42
+ }
43
+ if (result.kind === "empty") {
44
+ io.stdout("nothing to report");
45
+ return 0;
46
+ }
47
+ let exitCode = 0;
48
+ for (const target of result.targets) {
49
+ if ("accepted" in target) {
50
+ io.stdout(
51
+ `accepted ${target.accepted} days for ${result.machine} at ${target.url}`,
52
+ );
53
+ } else {
54
+ io.stderr(`${target.url}: ${target.message}`);
55
+ exitCode = 1;
56
+ }
57
+ }
58
+ return exitCode;
46
59
  }
47
60
  if (result.kind === "missing-config") {
48
61
  io.stderr(
package/src/collect.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AntigravityStep,
2
3
  antigravityConversationsDir,
3
4
  readAntigravitySteps,
4
5
  } from "./antigravity";
@@ -9,12 +10,19 @@ import {
9
10
  windowStart,
10
11
  } from "./ccusage";
11
12
  import { type CommandRunner, runCommand } from "./command";
12
- import { readConfig, runtimeTimezone } from "./config";
13
+ import { type CollectorTarget, readConfig, runtimeTimezone } from "./config";
14
+ import { type DevinStep, devinTranscriptsDir, readDevinSteps } from "./devin";
13
15
  import type { Fetcher } from "./http";
14
16
  import { type MachineIdentity, machineId } from "./machine";
15
- import { mapAntigravitySteps, mapCcusageDays } from "./mapping";
17
+ import {
18
+ antigravityProvider,
19
+ devinProvider,
20
+ mapAntigravitySteps,
21
+ mapCcusageDays,
22
+ mapDevinSteps,
23
+ } from "./mapping";
16
24
  import { type CollectorEnv, collectorPaths, processEnv } from "./paths";
17
- import { loadPrices } from "./pricing";
25
+ import { loadPrices, type PriceTable } from "./pricing";
18
26
  import type { UsageDay, UsageReport } from "./usage";
19
27
 
20
28
  export interface CollectOptions {
@@ -26,9 +34,18 @@ export interface CollectOptions {
26
34
  timezone?: string;
27
35
  }
28
36
 
37
+ export type TargetResult =
38
+ | { accepted: number; url: string }
39
+ | { message: string; url: string };
40
+
29
41
  export type CollectResult =
30
- | { kind: "reported"; accepted: number; machine: string }
31
- | { kind: "empty" }
42
+ | {
43
+ kind: "reported";
44
+ machine: string;
45
+ targets: TargetResult[];
46
+ warnings: string[];
47
+ }
48
+ | { kind: "empty"; warnings: string[] }
32
49
  | { kind: "missing-config"; configFile: string }
33
50
  | { kind: "failed"; message: string };
34
51
 
@@ -56,53 +73,100 @@ function acceptedCount(body: string): number | null {
56
73
  return typeof accepted === "number" ? accepted : null;
57
74
  }
58
75
 
76
+ interface LocalRows {
77
+ days: UsageDay[];
78
+ warnings: string[];
79
+ }
80
+
81
+ interface LocalSource<Step extends { at: Date }> {
82
+ map: (steps: Step[], timezone: string, prices: PriceTable) => UsageDay[];
83
+ provider: string;
84
+ read: (home: string) => Promise<{ failures: string[]; steps: Step[] }>;
85
+ }
86
+
87
+ interface LocalWindow {
88
+ fetcher: Fetcher;
89
+ home: string;
90
+ pricesFile: string;
91
+ since: string;
92
+ timezone: string;
93
+ today: Date;
94
+ }
95
+
96
+ const antigravitySource: LocalSource<AntigravityStep> = {
97
+ map: mapAntigravitySteps,
98
+ provider: antigravityProvider,
99
+ read: (home) => readAntigravitySteps(antigravityConversationsDir(home)),
100
+ };
101
+
102
+ const devinSource: LocalSource<DevinStep> = {
103
+ map: mapDevinSteps,
104
+ provider: devinProvider,
105
+ read: (home) => readDevinSteps(devinTranscriptsDir(home)),
106
+ };
107
+
59
108
  async function report(
60
109
  fetcher: Fetcher,
61
- url: string,
62
- key: string,
63
- usage: UsageReport,
64
- ): Promise<CollectResult> {
65
- const response = await fetcher(reportUrl(url), {
66
- body: JSON.stringify(usage),
67
- headers: {
68
- Authorization: `Bearer ${key}`,
69
- "Content-Type": "application/json",
70
- },
71
- method: "POST",
72
- });
73
- const body = await response.text();
74
- if (response.status !== 200) {
75
- return {
76
- kind: "failed",
77
- message: `tokenmax responded ${response.status}: ${body}`,
78
- };
79
- }
80
- const accepted = acceptedCount(body);
81
- if (accepted === null) {
82
- return {
83
- kind: "failed",
84
- message: `tokenmax responded an unexpected body: ${body}`,
85
- };
110
+ target: CollectorTarget,
111
+ body: string,
112
+ ): Promise<TargetResult> {
113
+ const { url } = target;
114
+ try {
115
+ const response = await fetcher(reportUrl(url), {
116
+ body,
117
+ headers: {
118
+ Authorization: `Bearer ${target.key}`,
119
+ "Content-Type": "application/json",
120
+ },
121
+ method: "POST",
122
+ });
123
+ const answer = await response.text();
124
+ if (response.status !== 200) {
125
+ return {
126
+ message: `tokenmax responded ${response.status}: ${answer}`,
127
+ url,
128
+ };
129
+ }
130
+ const accepted = acceptedCount(answer);
131
+ if (accepted === null) {
132
+ return {
133
+ message: `tokenmax responded an unexpected body: ${answer}`,
134
+ url,
135
+ };
136
+ }
137
+ return { accepted, url };
138
+ } catch (error) {
139
+ return { message: messageOf(error), url };
86
140
  }
87
- return { accepted, kind: "reported", machine: usage.machine };
88
141
  }
89
142
 
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 [];
143
+ async function localDays<Step extends { at: Date }>(
144
+ source: LocalSource<Step>,
145
+ window: LocalWindow,
146
+ ): Promise<LocalRows> {
147
+ try {
148
+ const usage = await source.read(window.home);
149
+ const warnings = usage.failures.map(
150
+ (failure) => `${source.provider}: skipped ${failure}`,
151
+ );
152
+ const steps = usage.steps.filter(
153
+ (step) => calendarDate(step.at, window.timezone) >= window.since,
154
+ );
155
+ if (steps.length === 0) {
156
+ return { days: [], warnings };
157
+ }
158
+ const prices = await loadPrices(
159
+ window.fetcher,
160
+ window.pricesFile,
161
+ window.today,
162
+ );
163
+ return { days: source.map(steps, window.timezone, prices), warnings };
164
+ } catch (error) {
165
+ return {
166
+ days: [],
167
+ warnings: [`${source.provider}: ${messageOf(error)}`],
168
+ };
103
169
  }
104
- const prices = await loadPrices(fetcher, pricesFile, today);
105
- return mapAntigravitySteps(steps, timezone, prices);
106
170
  }
107
171
 
108
172
  export async function collect(options: CollectOptions): Promise<CollectResult> {
@@ -133,30 +197,32 @@ export async function collect(options: CollectOptions): Promise<CollectResult> {
133
197
  sinceArgument(today, timezone),
134
198
  timezone,
135
199
  );
136
- days = [
137
- ...mapCcusageDays(daily),
138
- ...(await antigravityDays(
139
- env.home,
140
- today,
141
- timezone,
142
- fetcher,
143
- paths.pricesFile,
144
- )),
145
- ];
200
+ days = mapCcusageDays(daily);
146
201
  } catch (error) {
147
202
  return { kind: "failed", message: messageOf(error) };
148
203
  }
204
+
205
+ const window: LocalWindow = {
206
+ fetcher,
207
+ home: env.home,
208
+ pricesFile: paths.pricesFile,
209
+ since: windowStart(today, timezone),
210
+ timezone,
211
+ today,
212
+ };
213
+ const antigravity = await localDays(antigravitySource, window);
214
+ const devin = await localDays(devinSource, window);
215
+ days.push(...antigravity.days, ...devin.days);
216
+ const warnings = [...antigravity.warnings, ...devin.warnings];
149
217
  if (days.length === 0) {
150
- return { kind: "empty" };
218
+ return { kind: "empty", warnings };
151
219
  }
152
220
 
153
- try {
154
- return await report(fetcher, config.config.url, config.config.key, {
155
- days,
156
- machine,
157
- timezone,
158
- });
159
- } catch (error) {
160
- return { kind: "failed", message: messageOf(error) };
221
+ const usage: UsageReport = { days, machine, timezone };
222
+ const body = JSON.stringify(usage);
223
+ const targets: TargetResult[] = [];
224
+ for (const target of config.config.targets) {
225
+ targets.push(await report(fetcher, target, body));
161
226
  }
227
+ return { kind: "reported", machine, targets, warnings };
162
228
  }
package/src/config.ts CHANGED
@@ -2,12 +2,25 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
 
5
- export const collectorConfig = z.object({
5
+ export const collectorTarget = z.object({
6
6
  key: z.string().min(1),
7
- timezone: z.string().min(1).optional(),
8
7
  url: z.url({ protocol: /^https?$/ }),
9
8
  });
10
9
 
10
+ const timezone = z.string().min(1).optional();
11
+
12
+ export const collectorConfig = z.object({
13
+ targets: z.array(collectorTarget).min(1),
14
+ timezone,
15
+ });
16
+
17
+ const singleTargetConfig = collectorTarget
18
+ .extend({ timezone })
19
+ .transform(({ key, url, ...rest }) => ({ targets: [{ key, url }], ...rest }));
20
+
21
+ const storedConfig = z.union([collectorConfig, singleTargetConfig]);
22
+
23
+ export type CollectorTarget = z.infer<typeof collectorTarget>;
11
24
  export type CollectorConfig = z.infer<typeof collectorConfig>;
12
25
 
13
26
  export function canonicalTimezone(value: string): string | null {
@@ -62,7 +75,7 @@ export async function readConfig(
62
75
  };
63
76
  }
64
77
 
65
- const parsed = collectorConfig.safeParse(payload);
78
+ const parsed = storedConfig.safeParse(payload);
66
79
  if (!parsed.success) {
67
80
  return {
68
81
  kind: "invalid",
package/src/devin.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { z } from "zod";
4
+
5
+ export interface DevinStep {
6
+ at: Date;
7
+ cacheCreate: number;
8
+ cacheRead: number;
9
+ input: number;
10
+ model: string;
11
+ output: number;
12
+ }
13
+
14
+ export interface DevinUsage {
15
+ failures: string[];
16
+ steps: DevinStep[];
17
+ }
18
+
19
+ const unknownModel = "devin-unknown";
20
+
21
+ const metrics = z.object({
22
+ cached_tokens: z.int().optional(),
23
+ completion_tokens: z.int(),
24
+ extra: z
25
+ .object({ cache_creation_input_tokens: z.int().optional() })
26
+ .optional(),
27
+ prompt_tokens: z.int(),
28
+ });
29
+
30
+ const step = z.object({
31
+ metrics: metrics.optional(),
32
+ model_name: z.string().optional(),
33
+ source: z.string(),
34
+ timestamp: z.iso.datetime({ offset: true }),
35
+ });
36
+
37
+ const transcript = z.object({ steps: z.array(step) });
38
+
39
+ const issueText = (error: z.ZodError): string =>
40
+ error.issues
41
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
42
+ .join("; ");
43
+
44
+ export function devinTranscriptsDir(home: string): string {
45
+ return resolve(home, ".local", "share", "devin", "cli", "transcripts");
46
+ }
47
+
48
+ function parseTranscript(source: string): DevinStep[] {
49
+ const parsed = transcript.safeParse(JSON.parse(source));
50
+ if (!parsed.success) {
51
+ throw new Error(issueText(parsed.error));
52
+ }
53
+ const steps: DevinStep[] = [];
54
+ for (const entry of parsed.data.steps) {
55
+ if (entry.source !== "agent" || entry.metrics === undefined) {
56
+ continue;
57
+ }
58
+ const cacheRead = entry.metrics.cached_tokens ?? 0;
59
+ const cacheCreate = entry.metrics.extra?.cache_creation_input_tokens ?? 0;
60
+ steps.push({
61
+ at: new Date(entry.timestamp),
62
+ cacheCreate,
63
+ cacheRead,
64
+ input: entry.metrics.prompt_tokens - cacheRead - cacheCreate,
65
+ model: entry.model_name ?? unknownModel,
66
+ output: entry.metrics.completion_tokens,
67
+ });
68
+ }
69
+ return steps;
70
+ }
71
+
72
+ export async function readDevinSteps(dir: string): Promise<DevinUsage> {
73
+ let files: string[];
74
+ try {
75
+ files = await readdir(dir);
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
78
+ return { failures: [], steps: [] };
79
+ }
80
+ throw error;
81
+ }
82
+ const steps: DevinStep[] = [];
83
+ const failures: string[] = [];
84
+ for (const file of files.filter((name) => name.endsWith(".json")).sort()) {
85
+ const path = resolve(dir, file);
86
+ try {
87
+ steps.push(...parseTranscript(await readFile(path, "utf8")));
88
+ } catch (error) {
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ failures.push(`${path}: ${message}`);
91
+ }
92
+ }
93
+ return { failures, steps };
94
+ }
package/src/install.ts CHANGED
@@ -3,8 +3,10 @@ import { dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import {
5
5
  type CollectorConfig,
6
+ type CollectorTarget,
6
7
  canonicalTimezone,
7
8
  collectorConfig,
9
+ readConfig,
8
10
  runtimeTimezone,
9
11
  writeConfig,
10
12
  } from "./config";
@@ -67,6 +69,13 @@ function scheduleFiles(
67
69
  ];
68
70
  }
69
71
 
72
+ async function configuredTargets(
73
+ configFile: string,
74
+ ): Promise<CollectorTarget[]> {
75
+ const existing = await readConfig(configFile);
76
+ return existing.kind === "ok" ? existing.config.targets : [];
77
+ }
78
+
70
79
  export async function install(options: InstallOptions): Promise<InstallPlan> {
71
80
  const env = options.env ?? processEnv();
72
81
  const platform = resolvePlatform(options.platform ?? process.platform);
@@ -76,10 +85,12 @@ export async function install(options: InstallOptions): Promise<InstallPlan> {
76
85
  if (timezone === null) {
77
86
  throw new Error(`invalid timezone: ${requestedTimezone}`);
78
87
  }
88
+ const others = (await configuredTargets(paths.configFile)).filter(
89
+ (target) => target.url !== options.url,
90
+ );
79
91
  const config = collectorConfig.parse({
80
- key: options.key,
92
+ targets: [...others, { key: options.key, url: options.url }],
81
93
  timezone,
82
- url: options.url,
83
94
  });
84
95
  const cliPath = options.cliPath ?? resolveCliPath();
85
96
  if (
@@ -106,6 +117,9 @@ export async function install(options: InstallOptions): Promise<InstallPlan> {
106
117
  const log = options.log ?? console.log;
107
118
 
108
119
  log(`config: ${plan.configFile} (600, key redacted)`);
120
+ for (const target of plan.config.targets) {
121
+ log(`target: ${target.url}`);
122
+ }
109
123
  for (const file of plan.files) {
110
124
  log(`schedule: ${file.path}`);
111
125
  }
package/src/mapping.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import type { AntigravityStep } from "./antigravity";
2
2
  import { type CcusageDaily, calendarDate } from "./ccusage";
3
- import { costOf, type PriceTable } from "./pricing";
3
+ import type { DevinStep } from "./devin";
4
+ import { costOf, type ModelPrice, type PriceTable } from "./pricing";
4
5
  import type { UsageDay } from "./usage";
5
6
 
6
7
  export const antigravityProvider = "antigravity";
8
+ export const devinProvider = "devin";
7
9
 
8
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-");
9
16
 
10
17
  const rowKey = (day: UsageDay): string =>
11
18
  `${day.date}\u0000${day.provider}\u0000${day.model}`;
@@ -53,23 +60,33 @@ export function mapCcusageDays(output: CcusageDaily): UsageDay[] {
53
60
  return sortedRows(rows);
54
61
  }
55
62
 
56
- export function mapAntigravitySteps(
57
- steps: AntigravityStep[],
63
+ interface LocalStep {
64
+ at: Date;
65
+ cacheCreate?: number;
66
+ cacheRead: number;
67
+ input: number;
68
+ model: string;
69
+ output: number;
70
+ }
71
+
72
+ function mapLocalSteps(
73
+ steps: LocalStep[],
74
+ provider: string,
58
75
  timezone: string,
59
- prices: PriceTable,
76
+ priceOf: (model: string) => ModelPrice | undefined,
60
77
  ): UsageDay[] {
61
78
  const rows = new Map<string, UsageDay>();
62
79
 
63
80
  for (const step of steps) {
64
81
  const row: UsageDay = {
65
- cache_create: 0,
82
+ cache_create: step.cacheCreate ?? 0,
66
83
  cache_read: step.cacheRead,
67
84
  cost_usd: 0,
68
85
  date: calendarDate(step.at, timezone),
69
86
  input: step.input,
70
87
  model: step.model,
71
88
  output: step.output,
72
- provider: antigravityProvider,
89
+ provider,
73
90
  };
74
91
  if (isEmptyRow(row)) {
75
92
  continue;
@@ -80,17 +97,19 @@ export function mapAntigravitySteps(
80
97
  rows.set(key, row);
81
98
  continue;
82
99
  }
100
+ existing.cache_create += row.cache_create;
83
101
  existing.cache_read += row.cache_read;
84
102
  existing.input += row.input;
85
103
  existing.output += row.output;
86
104
  }
87
105
 
88
106
  for (const row of rows.values()) {
89
- const price = prices.get(row.model) ?? prices.get(`gemini/${row.model}`);
107
+ const price = priceOf(row.model);
90
108
  row.cost_usd =
91
109
  price === undefined
92
110
  ? 0
93
111
  : costOf(price, {
112
+ cacheCreate: row.cache_create,
94
113
  cacheRead: row.cache_read,
95
114
  input: row.input,
96
115
  output: row.output,
@@ -100,6 +119,32 @@ export function mapAntigravitySteps(
100
119
  return sortedRows(rows);
101
120
  }
102
121
 
122
+ export function mapAntigravitySteps(
123
+ steps: AntigravityStep[],
124
+ timezone: string,
125
+ prices: PriceTable,
126
+ ): UsageDay[] {
127
+ return mapLocalSteps(
128
+ steps,
129
+ antigravityProvider,
130
+ timezone,
131
+ (model) => prices.get(model) ?? prices.get(`gemini/${model}`),
132
+ );
133
+ }
134
+
135
+ export function mapDevinSteps(
136
+ steps: DevinStep[],
137
+ timezone: string,
138
+ prices: PriceTable,
139
+ ): UsageDay[] {
140
+ return mapLocalSteps(
141
+ steps.map((step) => ({ ...step, model: litellmModel(step.model) })),
142
+ devinProvider,
143
+ timezone,
144
+ (model) => prices.get(model),
145
+ );
146
+ }
147
+
103
148
  function sortedRows(rows: Map<string, UsageDay>): UsageDay[] {
104
149
  return [...rows.values()].sort(
105
150
  (a, b) =>
package/src/pricing.ts CHANGED
@@ -1,9 +1,10 @@
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";
5
5
 
6
6
  export interface ModelPrice {
7
+ cacheCreate: number;
7
8
  cacheRead: number;
8
9
  input: number;
9
10
  output: number;
@@ -12,6 +13,7 @@ export interface ModelPrice {
12
13
  export type PriceTable = Map<string, ModelPrice>;
13
14
 
14
15
  export interface PricedUsage {
16
+ cacheCreate?: number;
15
17
  cacheRead: number;
16
18
  input: number;
17
19
  output: number;
@@ -23,6 +25,7 @@ export const litellmPricesUrl =
23
25
  const maxAgeMs = 24 * 60 * 60 * 1000;
24
26
 
25
27
  const pricedEntry = z.object({
28
+ cache_creation_input_token_cost: z.number().optional(),
26
29
  cache_read_input_token_cost: z.number().optional(),
27
30
  input_cost_per_token: z.number(),
28
31
  output_cost_per_token: z.number(),
@@ -38,6 +41,7 @@ export function parseLitellmPrices(source: string): PriceTable {
38
41
  const priced = pricedEntry.safeParse(entry);
39
42
  if (priced.success) {
40
43
  table.set(model, {
44
+ cacheCreate: priced.data.cache_creation_input_token_cost ?? 0,
41
45
  cacheRead: priced.data.cache_read_input_token_cost ?? 0,
42
46
  input: priced.data.input_cost_per_token,
43
47
  output: priced.data.output_cost_per_token,
@@ -51,25 +55,39 @@ export function costOf(price: ModelPrice, usage: PricedUsage): number {
51
55
  return (
52
56
  usage.input * price.input +
53
57
  usage.output * price.output +
54
- usage.cacheRead * price.cacheRead
58
+ usage.cacheRead * price.cacheRead +
59
+ (usage.cacheCreate ?? 0) * price.cacheCreate
55
60
  );
56
61
  }
57
62
 
58
63
  async function readCache(
59
64
  pricesFile: string,
60
- ): Promise<{ modifiedAt: number; source: string } | null> {
65
+ ): Promise<{ modifiedAt: number; table: PriceTable } | null> {
66
+ let source: string;
67
+ let modifiedAt: number;
61
68
  try {
62
- const [source, info] = await Promise.all([
69
+ [source, modifiedAt] = await Promise.all([
63
70
  readFile(pricesFile, "utf8"),
64
- stat(pricesFile),
71
+ stat(pricesFile).then((info) => info.mtimeMs),
65
72
  ]);
66
- return { modifiedAt: info.mtimeMs, source };
67
73
  } catch (error) {
68
74
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
69
75
  return null;
70
76
  }
71
77
  throw error;
72
78
  }
79
+ try {
80
+ return { modifiedAt, table: parseLitellmPrices(source) };
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ async function writeCache(pricesFile: string, source: string): Promise<void> {
87
+ await mkdir(dirname(pricesFile), { recursive: true });
88
+ const partial = `${pricesFile}.${process.pid}.tmp`;
89
+ await writeFile(partial, source);
90
+ await rename(partial, pricesFile);
73
91
  }
74
92
 
75
93
  async function fetchPrices(fetcher: Fetcher): Promise<string> {
@@ -88,7 +106,7 @@ export async function loadPrices(
88
106
  ): Promise<PriceTable> {
89
107
  const cached = await readCache(pricesFile);
90
108
  if (cached !== null && now.getTime() - cached.modifiedAt < maxAgeMs) {
91
- return parseLitellmPrices(cached.source);
109
+ return cached.table;
92
110
  }
93
111
  let source: string;
94
112
  let table: PriceTable;
@@ -97,12 +115,11 @@ export async function loadPrices(
97
115
  table = parseLitellmPrices(source);
98
116
  } catch (error) {
99
117
  if (cached !== null) {
100
- return parseLitellmPrices(cached.source);
118
+ return cached.table;
101
119
  }
102
120
  const message = error instanceof Error ? error.message : String(error);
103
121
  throw new Error(`could not load model prices: ${message}`);
104
122
  }
105
- await mkdir(dirname(pricesFile), { recursive: true });
106
- await writeFile(pricesFile, source);
123
+ await writeCache(pricesFile, source);
107
124
  return table;
108
125
  }