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 +2 -2
- package/src/antigravity.ts +34 -8
- package/src/cli.ts +20 -7
- package/src/collect.ts +131 -65
- package/src/config.ts +16 -3
- package/src/devin.ts +94 -0
- package/src/install.ts +16 -2
- package/src/mapping.ts +52 -7
- package/src/pricing.ts +27 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmax-collector",
|
|
3
|
-
"version": "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.
|
|
22
|
+
"bun": ">=1.4.0"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"check-types": "tsc --noEmit",
|
package/src/antigravity.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 {
|
|
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
|
-
| {
|
|
31
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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;
|
|
65
|
+
): Promise<{ modifiedAt: number; table: PriceTable } | null> {
|
|
66
|
+
let source: string;
|
|
67
|
+
let modifiedAt: number;
|
|
61
68
|
try {
|
|
62
|
-
|
|
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
|
|
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
|
|
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
|
|
106
|
-
await writeFile(pricesFile, source);
|
|
123
|
+
await writeCache(pricesFile, source);
|
|
107
124
|
return table;
|
|
108
125
|
}
|