@cgaravitoq/aiusage 0.4.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/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +44 -0
- package/src/antigravity.ts +191 -0
- package/src/ccusage.ts +91 -0
- package/src/cli.ts +130 -0
- package/src/collect.ts +329 -0
- package/src/command.ts +94 -0
- package/src/config.ts +101 -0
- package/src/devin.ts +124 -0
- package/src/http.ts +6 -0
- package/src/install.ts +162 -0
- package/src/machine.ts +56 -0
- package/src/mapping.ts +172 -0
- package/src/paths.ts +70 -0
- package/src/pricing.ts +137 -0
- package/src/schedule.ts +86 -0
- package/src/usage.ts +17 -0
package/src/collect.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AntigravityStep,
|
|
3
|
+
antigravityConversationsDir,
|
|
4
|
+
readAntigravitySteps,
|
|
5
|
+
} from "./antigravity";
|
|
6
|
+
import {
|
|
7
|
+
calendarDate,
|
|
8
|
+
readCcusageDaily,
|
|
9
|
+
sinceArgument,
|
|
10
|
+
windowStart,
|
|
11
|
+
} from "./ccusage";
|
|
12
|
+
import { type CommandRunner, runCommand } from "./command";
|
|
13
|
+
import { type CollectorTarget, readConfig, runtimeTimezone } from "./config";
|
|
14
|
+
import { type DevinStep, devinTranscriptsDir, readDevinSteps } from "./devin";
|
|
15
|
+
import type { Fetcher } from "./http";
|
|
16
|
+
import { type MachineIdentity, machineId } from "./machine";
|
|
17
|
+
import {
|
|
18
|
+
antigravityProvider,
|
|
19
|
+
ccusageProviders,
|
|
20
|
+
devinProvider,
|
|
21
|
+
type MappedDays,
|
|
22
|
+
mapAntigravitySteps,
|
|
23
|
+
mapCcusageDays,
|
|
24
|
+
mapDevinSteps,
|
|
25
|
+
} from "./mapping";
|
|
26
|
+
import { type CollectorEnv, collectorPaths, processEnv } from "./paths";
|
|
27
|
+
import { loadPrices, type PriceTable } from "./pricing";
|
|
28
|
+
import type { UsageDay, UsageReport } from "./usage";
|
|
29
|
+
|
|
30
|
+
export interface CollectOptions {
|
|
31
|
+
identity: MachineIdentity;
|
|
32
|
+
env?: CollectorEnv;
|
|
33
|
+
fetcher?: Fetcher;
|
|
34
|
+
requestTimeoutMs?: number;
|
|
35
|
+
runner?: CommandRunner;
|
|
36
|
+
today?: Date;
|
|
37
|
+
timezone?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type TargetResult =
|
|
41
|
+
| { accepted: number; url: string }
|
|
42
|
+
| { message: string; url: string };
|
|
43
|
+
|
|
44
|
+
export type CollectResult =
|
|
45
|
+
| {
|
|
46
|
+
kind: "reported";
|
|
47
|
+
machine: string;
|
|
48
|
+
targets: TargetResult[];
|
|
49
|
+
warnings: string[];
|
|
50
|
+
}
|
|
51
|
+
| { kind: "empty"; warnings: string[] }
|
|
52
|
+
| { kind: "missing-config"; configFile: string }
|
|
53
|
+
| { kind: "failed"; message: string; warnings: string[] };
|
|
54
|
+
|
|
55
|
+
const messageOf = (error: unknown): string =>
|
|
56
|
+
error instanceof Error ? error.message : String(error);
|
|
57
|
+
|
|
58
|
+
const fetchTimeoutMs = 30_000;
|
|
59
|
+
const sliceSize = 1000;
|
|
60
|
+
|
|
61
|
+
const reportUrl = (baseUrl: string): string =>
|
|
62
|
+
`${baseUrl.replace(/\/+$/, "")}/api/report`;
|
|
63
|
+
|
|
64
|
+
const providerDay = (day: UsageDay): string =>
|
|
65
|
+
`${day.date}\u0000${day.provider}`;
|
|
66
|
+
|
|
67
|
+
function acceptedCount(body: string): number | null {
|
|
68
|
+
let payload: unknown;
|
|
69
|
+
try {
|
|
70
|
+
payload = JSON.parse(body);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
typeof payload !== "object" ||
|
|
76
|
+
payload === null ||
|
|
77
|
+
!("accepted" in payload)
|
|
78
|
+
) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const accepted = payload.accepted;
|
|
82
|
+
if (
|
|
83
|
+
typeof accepted !== "number" ||
|
|
84
|
+
!Number.isInteger(accepted) ||
|
|
85
|
+
accepted < 0
|
|
86
|
+
) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return accepted;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface LocalSource<Step extends { at: Date }> {
|
|
93
|
+
map: (steps: Step[], timezone: string, prices: PriceTable) => MappedDays;
|
|
94
|
+
provider: string;
|
|
95
|
+
read: (home: string) => Promise<{ failures: string[]; steps: Step[] }>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface LocalWindow {
|
|
99
|
+
fetcher: Fetcher;
|
|
100
|
+
home: string;
|
|
101
|
+
pricesFile: string;
|
|
102
|
+
requestTimeoutMs: number;
|
|
103
|
+
since: string;
|
|
104
|
+
timezone: string;
|
|
105
|
+
today: Date;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const antigravitySource: LocalSource<AntigravityStep> = {
|
|
109
|
+
map: mapAntigravitySteps,
|
|
110
|
+
provider: antigravityProvider,
|
|
111
|
+
read: (home) => readAntigravitySteps(antigravityConversationsDir(home)),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const devinSource: LocalSource<DevinStep> = {
|
|
115
|
+
map: mapDevinSteps,
|
|
116
|
+
provider: devinProvider,
|
|
117
|
+
read: (home) => readDevinSteps(devinTranscriptsDir(home)),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
async function report(
|
|
121
|
+
fetcher: Fetcher,
|
|
122
|
+
target: CollectorTarget,
|
|
123
|
+
body: string,
|
|
124
|
+
requestTimeoutMs: number,
|
|
125
|
+
): Promise<TargetResult> {
|
|
126
|
+
const { url } = target;
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetcher(reportUrl(url), {
|
|
129
|
+
body,
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${target.key}`,
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
},
|
|
134
|
+
method: "POST",
|
|
135
|
+
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
136
|
+
});
|
|
137
|
+
const answer = await response.text();
|
|
138
|
+
if (response.status !== 200) {
|
|
139
|
+
return {
|
|
140
|
+
message: `aiusage responded ${response.status}: ${answer}`,
|
|
141
|
+
url,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const accepted = acceptedCount(answer);
|
|
145
|
+
if (accepted === null) {
|
|
146
|
+
return {
|
|
147
|
+
message: `aiusage responded an unexpected body: ${answer}`,
|
|
148
|
+
url,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { accepted, url };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return { message: messageOf(error), url };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function daySlices(days: UsageDay[], size: number): UsageDay[][] {
|
|
158
|
+
const groups: UsageDay[][] = [];
|
|
159
|
+
for (const day of days) {
|
|
160
|
+
const group = groups.at(-1);
|
|
161
|
+
if (group !== undefined && group[0].date === day.date) {
|
|
162
|
+
group.push(day);
|
|
163
|
+
} else {
|
|
164
|
+
groups.push([day]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const slices: UsageDay[][] = [];
|
|
169
|
+
let current: UsageDay[] = [];
|
|
170
|
+
for (const group of groups) {
|
|
171
|
+
if (current.length > 0 && current.length + group.length > size) {
|
|
172
|
+
slices.push(current);
|
|
173
|
+
current = [];
|
|
174
|
+
}
|
|
175
|
+
current.push(...group);
|
|
176
|
+
}
|
|
177
|
+
if (current.length > 0) {
|
|
178
|
+
slices.push(current);
|
|
179
|
+
}
|
|
180
|
+
return slices;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function reportTarget(
|
|
184
|
+
fetcher: Fetcher,
|
|
185
|
+
target: CollectorTarget,
|
|
186
|
+
usage: UsageReport,
|
|
187
|
+
requestTimeoutMs: number,
|
|
188
|
+
): Promise<TargetResult> {
|
|
189
|
+
let accepted = 0;
|
|
190
|
+
for (const slice of daySlices(usage.days, sliceSize)) {
|
|
191
|
+
const result = await report(
|
|
192
|
+
fetcher,
|
|
193
|
+
target,
|
|
194
|
+
JSON.stringify({ ...usage, days: slice }),
|
|
195
|
+
requestTimeoutMs,
|
|
196
|
+
);
|
|
197
|
+
if ("message" in result) {
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
accepted += result.accepted;
|
|
201
|
+
}
|
|
202
|
+
return { accepted, url: target.url };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface LocalDays extends MappedDays {
|
|
206
|
+
providers: string[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function localDays<Step extends { at: Date }>(
|
|
210
|
+
source: LocalSource<Step>,
|
|
211
|
+
window: LocalWindow,
|
|
212
|
+
): Promise<LocalDays> {
|
|
213
|
+
try {
|
|
214
|
+
const usage = await source.read(window.home);
|
|
215
|
+
const warnings = usage.failures.map(
|
|
216
|
+
(failure) => `${source.provider}: skipped ${failure}`,
|
|
217
|
+
);
|
|
218
|
+
const providers = usage.failures.length === 0 ? [source.provider] : [];
|
|
219
|
+
const steps = usage.steps.filter(
|
|
220
|
+
(step) => calendarDate(step.at, window.timezone) >= window.since,
|
|
221
|
+
);
|
|
222
|
+
if (steps.length === 0) {
|
|
223
|
+
return { days: [], providers, warnings };
|
|
224
|
+
}
|
|
225
|
+
const prices = await loadPrices(
|
|
226
|
+
window.fetcher,
|
|
227
|
+
window.pricesFile,
|
|
228
|
+
window.today,
|
|
229
|
+
window.requestTimeoutMs,
|
|
230
|
+
);
|
|
231
|
+
const mapped = source.map(steps, window.timezone, prices);
|
|
232
|
+
return {
|
|
233
|
+
days: mapped.days,
|
|
234
|
+
providers,
|
|
235
|
+
warnings: [...warnings, ...mapped.warnings],
|
|
236
|
+
};
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return {
|
|
239
|
+
days: [],
|
|
240
|
+
providers: [],
|
|
241
|
+
warnings: [`${source.provider}: ${messageOf(error)}`],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function collect(options: CollectOptions): Promise<CollectResult> {
|
|
247
|
+
const env = options.env ?? processEnv();
|
|
248
|
+
const paths = collectorPaths(env);
|
|
249
|
+
const config = await readConfig(paths.configFile);
|
|
250
|
+
if (config.kind === "missing") {
|
|
251
|
+
return { configFile: paths.configFile, kind: "missing-config" };
|
|
252
|
+
}
|
|
253
|
+
if (config.kind === "invalid") {
|
|
254
|
+
return { kind: "failed", message: config.message, warnings: [] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const machine = machineId(
|
|
258
|
+
options.identity.hostname,
|
|
259
|
+
options.identity.platformUuid,
|
|
260
|
+
);
|
|
261
|
+
const timezone =
|
|
262
|
+
config.config.timezone ?? options.timezone ?? runtimeTimezone();
|
|
263
|
+
const runner = options.runner ?? runCommand;
|
|
264
|
+
const fetcher = options.fetcher ?? fetch;
|
|
265
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? fetchTimeoutMs;
|
|
266
|
+
const today = options.today ?? new Date();
|
|
267
|
+
|
|
268
|
+
let days: UsageDay[];
|
|
269
|
+
let ccusageFailure: string | null = null;
|
|
270
|
+
let ccusageCovered: string[] = [];
|
|
271
|
+
try {
|
|
272
|
+
const daily = await readCcusageDaily(
|
|
273
|
+
runner,
|
|
274
|
+
sinceArgument(today, timezone),
|
|
275
|
+
timezone,
|
|
276
|
+
);
|
|
277
|
+
days = mapCcusageDays(daily);
|
|
278
|
+
ccusageCovered = ccusageProviders(daily);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
ccusageFailure = messageOf(error);
|
|
281
|
+
days = [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const window: LocalWindow = {
|
|
285
|
+
fetcher,
|
|
286
|
+
home: env.home,
|
|
287
|
+
pricesFile: paths.pricesFile,
|
|
288
|
+
requestTimeoutMs,
|
|
289
|
+
since: windowStart(today, timezone),
|
|
290
|
+
timezone,
|
|
291
|
+
today,
|
|
292
|
+
};
|
|
293
|
+
const antigravity = await localDays(antigravitySource, window);
|
|
294
|
+
const devin = await localDays(devinSource, window);
|
|
295
|
+
const covered = new Set(days.map(providerDay));
|
|
296
|
+
days.push(
|
|
297
|
+
...[...antigravity.days, ...devin.days].filter(
|
|
298
|
+
(day) => !covered.has(providerDay(day)),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
days.sort((a, b) => a.date.localeCompare(b.date));
|
|
302
|
+
const warnings =
|
|
303
|
+
ccusageFailure === null ? [] : [`ccusage: ${ccusageFailure}`];
|
|
304
|
+
warnings.push(...antigravity.warnings, ...devin.warnings);
|
|
305
|
+
if (days.length === 0) {
|
|
306
|
+
if (ccusageFailure !== null) {
|
|
307
|
+
return { kind: "failed", message: ccusageFailure, warnings };
|
|
308
|
+
}
|
|
309
|
+
return { kind: "empty", warnings };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const usage: UsageReport = {
|
|
313
|
+
days,
|
|
314
|
+
machine,
|
|
315
|
+
providers: [
|
|
316
|
+
...new Set([
|
|
317
|
+
...ccusageCovered,
|
|
318
|
+
...antigravity.providers,
|
|
319
|
+
...devin.providers,
|
|
320
|
+
]),
|
|
321
|
+
].sort(),
|
|
322
|
+
timezone,
|
|
323
|
+
};
|
|
324
|
+
const targets: TargetResult[] = [];
|
|
325
|
+
for (const target of config.config.targets) {
|
|
326
|
+
targets.push(await reportTarget(fetcher, target, usage, requestTimeoutMs));
|
|
327
|
+
}
|
|
328
|
+
return { kind: "reported", machine, targets, warnings };
|
|
329
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface CommandResult {
|
|
4
|
+
exitCode: number;
|
|
5
|
+
stderr: string;
|
|
6
|
+
stdout: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CommandRunner = (
|
|
10
|
+
command: string,
|
|
11
|
+
args: string[],
|
|
12
|
+
timeoutMs?: number,
|
|
13
|
+
) => Promise<CommandResult>;
|
|
14
|
+
|
|
15
|
+
const defaultTimeoutMs = 120_000;
|
|
16
|
+
const maxOutputBytes = 8 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const toText = (chunks: Buffer[]): string =>
|
|
19
|
+
Buffer.concat(chunks).toString("utf8");
|
|
20
|
+
|
|
21
|
+
function killGroup(child: ChildProcess): void {
|
|
22
|
+
if (child.pid === undefined) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
process.kill(-child.pid, "SIGKILL");
|
|
27
|
+
} catch {
|
|
28
|
+
child.kill("SIGKILL");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runCommand(
|
|
33
|
+
command: string,
|
|
34
|
+
args: string[],
|
|
35
|
+
timeoutMs = defaultTimeoutMs,
|
|
36
|
+
): Promise<CommandResult> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = spawn(command, args, {
|
|
39
|
+
detached: true,
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
});
|
|
42
|
+
const stdout: Buffer[] = [];
|
|
43
|
+
const stderr: Buffer[] = [];
|
|
44
|
+
let outputBytes = 0;
|
|
45
|
+
let settled = false;
|
|
46
|
+
let timer: NodeJS.Timeout | undefined;
|
|
47
|
+
const finish = (result: CommandResult): void => {
|
|
48
|
+
if (settled) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
settled = true;
|
|
52
|
+
if (timer !== undefined) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
resolve(result);
|
|
56
|
+
};
|
|
57
|
+
const push = (chunks: Buffer[], chunk: Buffer): void => {
|
|
58
|
+
if (settled) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
outputBytes += chunk.length;
|
|
62
|
+
if (outputBytes > maxOutputBytes) {
|
|
63
|
+
killGroup(child);
|
|
64
|
+
finish({
|
|
65
|
+
exitCode: 1,
|
|
66
|
+
stderr: `output exceeded ${maxOutputBytes} bytes`,
|
|
67
|
+
stdout: "",
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
};
|
|
73
|
+
timer = setTimeout(() => {
|
|
74
|
+
killGroup(child);
|
|
75
|
+
finish({
|
|
76
|
+
exitCode: 1,
|
|
77
|
+
stderr: `timed out after ${timeoutMs}ms`,
|
|
78
|
+
stdout: "",
|
|
79
|
+
});
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
child.stdout?.on("data", (chunk: Buffer) => push(stdout, chunk));
|
|
82
|
+
child.stderr?.on("data", (chunk: Buffer) => push(stderr, chunk));
|
|
83
|
+
child.on("error", (error: Error) => {
|
|
84
|
+
finish({ exitCode: 1, stderr: error.message, stdout: "" });
|
|
85
|
+
});
|
|
86
|
+
child.on("close", (exitCode: number | null) => {
|
|
87
|
+
finish({
|
|
88
|
+
exitCode: exitCode ?? 1,
|
|
89
|
+
stderr: toText(stderr),
|
|
90
|
+
stdout: toText(stdout),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const collectorTarget = z.object({
|
|
6
|
+
key: z.string().min(1),
|
|
7
|
+
url: z.url({ protocol: /^https?$/ }),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const timezone = z
|
|
11
|
+
.string()
|
|
12
|
+
.refine((value) => canonicalTimezone(value) !== null, "invalid timezone")
|
|
13
|
+
.optional();
|
|
14
|
+
|
|
15
|
+
export const collectorConfig = z.object({
|
|
16
|
+
targets: z.array(collectorTarget).min(1),
|
|
17
|
+
timezone,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const singleTargetConfig = collectorTarget
|
|
21
|
+
.extend({ timezone })
|
|
22
|
+
.transform(({ key, url, ...rest }) => ({ targets: [{ key, url }], ...rest }));
|
|
23
|
+
|
|
24
|
+
const storedConfig = z.union([collectorConfig, singleTargetConfig]);
|
|
25
|
+
|
|
26
|
+
export type CollectorTarget = z.infer<typeof collectorTarget>;
|
|
27
|
+
export type CollectorConfig = z.infer<typeof collectorConfig>;
|
|
28
|
+
|
|
29
|
+
export function canonicalTimezone(value: string): string | null {
|
|
30
|
+
try {
|
|
31
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
32
|
+
timeZone: value,
|
|
33
|
+
}).resolvedOptions().timeZone;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function runtimeTimezone(): string {
|
|
40
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ConfigReadResult =
|
|
44
|
+
| { kind: "ok"; config: CollectorConfig }
|
|
45
|
+
| { kind: "missing" }
|
|
46
|
+
| { kind: "invalid"; message: string };
|
|
47
|
+
|
|
48
|
+
const issueText = (error: z.ZodError): string =>
|
|
49
|
+
error.issues
|
|
50
|
+
.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
|
|
51
|
+
.join("; ");
|
|
52
|
+
|
|
53
|
+
export async function readConfig(
|
|
54
|
+
configFile: string,
|
|
55
|
+
): Promise<ConfigReadResult> {
|
|
56
|
+
let source: string;
|
|
57
|
+
try {
|
|
58
|
+
source = await fsp.readFile(configFile, "utf8");
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
61
|
+
return { kind: "missing" };
|
|
62
|
+
}
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
return {
|
|
65
|
+
kind: "invalid",
|
|
66
|
+
message: `could not read aiusage config at ${configFile}: ${message}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let payload: unknown;
|
|
71
|
+
try {
|
|
72
|
+
payload = JSON.parse(source);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
+
return {
|
|
76
|
+
kind: "invalid",
|
|
77
|
+
message: `could not parse aiusage config at ${configFile}: ${message}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parsed = storedConfig.safeParse(payload);
|
|
82
|
+
if (!parsed.success) {
|
|
83
|
+
return {
|
|
84
|
+
kind: "invalid",
|
|
85
|
+
message: `invalid aiusage config at ${configFile}: ${issueText(parsed.error)}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { kind: "ok", config: parsed.data };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function writeConfig(
|
|
92
|
+
configFile: string,
|
|
93
|
+
config: CollectorConfig,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
await fsp.mkdir(dirname(configFile), { recursive: true });
|
|
96
|
+
const temporary = `${configFile}.tmp`;
|
|
97
|
+
await fsp.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, {
|
|
98
|
+
mode: 0o600,
|
|
99
|
+
});
|
|
100
|
+
await fsp.rename(temporary, configFile);
|
|
101
|
+
}
|
package/src/devin.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
export interface ReaderSteps<Step> {
|
|
20
|
+
failures: string[];
|
|
21
|
+
steps: Step[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const messageOf = (error: unknown): string =>
|
|
25
|
+
error instanceof Error ? error.message : String(error);
|
|
26
|
+
|
|
27
|
+
const unknownModel = "devin-unknown";
|
|
28
|
+
|
|
29
|
+
const metrics = z.object({
|
|
30
|
+
cached_tokens: z.int().optional(),
|
|
31
|
+
completion_tokens: z.int(),
|
|
32
|
+
extra: z
|
|
33
|
+
.object({ cache_creation_input_tokens: z.int().optional() })
|
|
34
|
+
.optional(),
|
|
35
|
+
prompt_tokens: z.int(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const step = z.object({
|
|
39
|
+
metrics: metrics.optional(),
|
|
40
|
+
model_name: z.string().optional(),
|
|
41
|
+
source: z.string(),
|
|
42
|
+
step_id: z.unknown().optional(),
|
|
43
|
+
timestamp: z.iso.datetime({ offset: true }),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const transcript = z.object({ steps: z.array(step) });
|
|
47
|
+
|
|
48
|
+
const issueText = (error: z.ZodError): string =>
|
|
49
|
+
error.issues
|
|
50
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
51
|
+
.join("; ");
|
|
52
|
+
|
|
53
|
+
export function devinTranscriptsDir(home: string): string {
|
|
54
|
+
return resolve(home, ".local", "share", "devin", "cli", "transcripts");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseTranscript(source: string): ReaderSteps<DevinStep> {
|
|
58
|
+
const parsed = transcript.safeParse(JSON.parse(source));
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
throw new Error(issueText(parsed.error));
|
|
61
|
+
}
|
|
62
|
+
const steps: DevinStep[] = [];
|
|
63
|
+
const failures: string[] = [];
|
|
64
|
+
for (const entry of parsed.data.steps) {
|
|
65
|
+
if (entry.source !== "agent" || entry.metrics === undefined) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const cacheRead = entry.metrics.cached_tokens ?? 0;
|
|
69
|
+
const cacheCreate = entry.metrics.extra?.cache_creation_input_tokens ?? 0;
|
|
70
|
+
const input = entry.metrics.prompt_tokens - cacheRead - cacheCreate;
|
|
71
|
+
if (input < 0) {
|
|
72
|
+
failures.push(
|
|
73
|
+
`step ${entry.step_id ?? "?"} reports more cached than prompt tokens`,
|
|
74
|
+
);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
steps.push({
|
|
78
|
+
at: new Date(entry.timestamp),
|
|
79
|
+
cacheCreate,
|
|
80
|
+
cacheRead,
|
|
81
|
+
input,
|
|
82
|
+
model: entry.model_name ?? unknownModel,
|
|
83
|
+
output: entry.metrics.completion_tokens,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { failures, steps };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function readDirectorySteps<Step>(
|
|
90
|
+
dir: string,
|
|
91
|
+
suffix: string,
|
|
92
|
+
read: (file: string) => Promise<ReaderSteps<Step>>,
|
|
93
|
+
): Promise<ReaderSteps<Step>> {
|
|
94
|
+
let files: string[];
|
|
95
|
+
try {
|
|
96
|
+
files = await readdir(dir);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
99
|
+
return { failures: [], steps: [] };
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
const steps: Step[] = [];
|
|
104
|
+
const failures: string[] = [];
|
|
105
|
+
for (const name of files.filter((file) => file.endsWith(suffix)).sort()) {
|
|
106
|
+
const file = resolve(dir, name);
|
|
107
|
+
try {
|
|
108
|
+
const readFile = await read(file);
|
|
109
|
+
steps.push(...readFile.steps);
|
|
110
|
+
failures.push(
|
|
111
|
+
...readFile.failures.map((failure) => `${file}: ${failure}`),
|
|
112
|
+
);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
failures.push(`${file}: ${messageOf(error)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { failures, steps };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function readDevinSteps(dir: string): Promise<DevinUsage> {
|
|
121
|
+
return readDirectorySteps(dir, ".json", async (file) =>
|
|
122
|
+
parseTranscript(await readFile(file, "utf8")),
|
|
123
|
+
);
|
|
124
|
+
}
|