opencode-token-tracker 1.5.1 → 1.5.3
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/dist/bin/opencode-tokens.js +51 -14
- package/dist/index.js +130 -122
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "../lib/shared.js";
|
|
3
|
-
import { readFileSync, existsSync, writeFileSync } from "fs";
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, openSync, readSync, closeSync, statSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
@@ -22,25 +22,62 @@ function loadEntries(since) {
|
|
|
22
22
|
if (!existsSync(LOG_FILE)) {
|
|
23
23
|
return [];
|
|
24
24
|
}
|
|
25
|
-
const content = readFileSync(LOG_FILE, "utf-8");
|
|
26
|
-
const lines = content.trim().split("\n").filter(Boolean);
|
|
27
25
|
const entries = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
const fd = openSync(LOG_FILE, "r");
|
|
27
|
+
const stat = statSync(LOG_FILE);
|
|
28
|
+
const fileSize = stat.size;
|
|
29
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
30
|
+
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
31
|
+
let filePos = fileSize;
|
|
32
|
+
let leftover = "";
|
|
33
|
+
let shouldStop = false;
|
|
34
|
+
while (filePos > 0 && !shouldStop) {
|
|
35
|
+
const readLength = Math.min(CHUNK_SIZE, filePos);
|
|
36
|
+
filePos -= readLength;
|
|
37
|
+
readSync(fd, buffer, 0, readLength, filePos);
|
|
38
|
+
const chunkStr = buffer.toString("utf8", 0, readLength) + leftover;
|
|
39
|
+
const lines = chunkStr.split("\n");
|
|
40
|
+
// The leftmost line could be cut off, save it for the next chunk read to the left
|
|
41
|
+
leftover = lines[0];
|
|
42
|
+
// Iterate lines in reverse order (from end to start)
|
|
43
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
44
|
+
const line = lines[i].trim();
|
|
45
|
+
if (!line)
|
|
36
46
|
continue;
|
|
37
|
-
|
|
47
|
+
try {
|
|
48
|
+
const entry = JSON.parse(line);
|
|
49
|
+
if (entry.type !== "tokens")
|
|
50
|
+
continue;
|
|
51
|
+
// Early break pruning: once we hit a record older than the threshold,
|
|
52
|
+
// we can safely stop reading earlier history thanks to monotonic time progression in JSONL.
|
|
53
|
+
if (since && entry._ts < since) {
|
|
54
|
+
shouldStop = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (!entry.input && !entry.output)
|
|
58
|
+
continue;
|
|
59
|
+
entries.push(entry);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Skip malformed lines
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Include the very first line at the top
|
|
67
|
+
if (!shouldStop && leftover.trim()) {
|
|
68
|
+
try {
|
|
69
|
+
const entry = JSON.parse(leftover.trim());
|
|
70
|
+
if (entry.type === "tokens" && (!since || entry._ts >= since) && (entry.input || entry.output)) {
|
|
71
|
+
entries.push(entry);
|
|
72
|
+
}
|
|
38
73
|
}
|
|
39
74
|
catch {
|
|
40
|
-
//
|
|
75
|
+
// Ignore
|
|
41
76
|
}
|
|
42
77
|
}
|
|
43
|
-
|
|
78
|
+
closeSync(fd);
|
|
79
|
+
// Re-establish original chronological order
|
|
80
|
+
return entries.reverse();
|
|
44
81
|
}
|
|
45
82
|
function loadConfig() {
|
|
46
83
|
try {
|
package/dist/index.js
CHANGED
|
@@ -267,140 +267,148 @@ function formatBudgetMessage(status) {
|
|
|
267
267
|
return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
|
|
268
268
|
}
|
|
269
269
|
export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
await client.tui.showToast({
|
|
279
|
-
body: {
|
|
280
|
-
title: "Token Tracker: config warning",
|
|
281
|
-
message: configWarnings.join("; "),
|
|
282
|
-
variant: "warning",
|
|
283
|
-
duration: 5000,
|
|
284
|
-
},
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
catch { }
|
|
288
|
-
}
|
|
289
|
-
return {
|
|
290
|
-
event: async ({ event }) => {
|
|
270
|
+
try {
|
|
271
|
+
// Load config on plugin init (with validation)
|
|
272
|
+
config = loadConfig();
|
|
273
|
+
// Initialize in-memory budget tracker (reads JSONL once)
|
|
274
|
+
initBudgetTracker();
|
|
275
|
+
logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
|
|
276
|
+
// Show config validation warnings via Toast
|
|
277
|
+
if (configWarnings.length > 0) {
|
|
291
278
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
279
|
+
await client.tui.showToast({
|
|
280
|
+
body: {
|
|
281
|
+
title: "Token Tracker: config warning",
|
|
282
|
+
message: configWarnings.join("; "),
|
|
283
|
+
variant: "warning",
|
|
284
|
+
duration: 5000,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch { }
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
event: async ({ event }) => {
|
|
292
|
+
try {
|
|
293
|
+
// Handle message updates (token tracking)
|
|
294
|
+
if (event.type === "message.updated") {
|
|
295
|
+
const props = event.properties;
|
|
296
|
+
const info = props?.info;
|
|
297
|
+
if (!info?.tokens)
|
|
298
|
+
return;
|
|
299
|
+
const messageId = info.id;
|
|
300
|
+
const sessionId = info.sessionID;
|
|
301
|
+
if (!messageId || !sessionId)
|
|
302
|
+
return;
|
|
303
|
+
const input = info.tokens.input ?? 0;
|
|
304
|
+
const output = info.tokens.output ?? 0;
|
|
305
|
+
const reasoning = info.tokens.reasoning ?? 0;
|
|
306
|
+
const cacheRead = info.tokens.cache?.read ?? 0;
|
|
307
|
+
const cacheWrite = info.tokens.cache?.write ?? 0;
|
|
308
|
+
const hasTokens = input > 0 || output > 0;
|
|
309
|
+
if (!hasTokens)
|
|
310
|
+
return;
|
|
311
|
+
const dedupeKey = `${messageId}-${input}-${output}`;
|
|
312
|
+
if (isDuplicate(dedupeKey))
|
|
313
|
+
return;
|
|
314
|
+
const model = info.model?.modelID ?? info.modelID ?? "unknown";
|
|
315
|
+
const provider = info.model?.providerID ?? info.providerID ?? "unknown";
|
|
316
|
+
const cost = calculateCost(model, provider, input, output, cacheRead, cacheWrite);
|
|
317
|
+
// Update session stats
|
|
318
|
+
const stats = getOrCreateSessionStats(sessionId);
|
|
319
|
+
stats.totalInput += input;
|
|
320
|
+
stats.totalOutput += output;
|
|
321
|
+
stats.totalReasoning += reasoning;
|
|
322
|
+
stats.totalCacheRead += cacheRead;
|
|
323
|
+
stats.totalCacheWrite += cacheWrite;
|
|
324
|
+
stats.totalCost += cost;
|
|
325
|
+
stats.messageCount += 1;
|
|
326
|
+
// Log to file
|
|
327
|
+
logJson({
|
|
328
|
+
type: "tokens",
|
|
329
|
+
sessionId,
|
|
330
|
+
messageId,
|
|
331
|
+
role: info.role,
|
|
332
|
+
agent: info.agent,
|
|
333
|
+
model,
|
|
334
|
+
provider,
|
|
335
|
+
input,
|
|
336
|
+
output,
|
|
337
|
+
reasoning,
|
|
338
|
+
cacheRead,
|
|
339
|
+
cacheWrite,
|
|
340
|
+
cost,
|
|
341
|
+
});
|
|
342
|
+
// Accumulate cost into in-memory budget tracker
|
|
343
|
+
accumulateBudget(cost);
|
|
344
|
+
// Show toast for this message
|
|
345
|
+
if (config.toast.enabled) {
|
|
346
|
+
const totalTokens = input + output;
|
|
347
|
+
// Check budget status
|
|
348
|
+
const budgetStatus = checkBudgetStatus();
|
|
349
|
+
let title = `${formatTokens(totalTokens)} tokens`;
|
|
350
|
+
let message = `${formatCost(cost)} | Session: ${formatCost(stats.totalCost)}`;
|
|
351
|
+
let variant = "info";
|
|
352
|
+
// Add budget warning/alert if applicable
|
|
353
|
+
if (budgetStatus) {
|
|
354
|
+
if (budgetStatus.exceeded) {
|
|
355
|
+
title = `⚠️ Budget exceeded!`;
|
|
356
|
+
message = formatBudgetMessage(budgetStatus);
|
|
357
|
+
variant = "error";
|
|
358
|
+
}
|
|
359
|
+
else if (budgetStatus.warning) {
|
|
360
|
+
message = `${formatCost(cost)} | ${formatBudgetMessage(budgetStatus)}`;
|
|
361
|
+
variant = "warning";
|
|
362
|
+
}
|
|
357
363
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
364
|
+
try {
|
|
365
|
+
await client.tui.showToast({
|
|
366
|
+
body: {
|
|
367
|
+
title,
|
|
368
|
+
message,
|
|
369
|
+
variant,
|
|
370
|
+
duration: budgetStatus?.exceeded ? 5000 : config.toast.duration,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
361
373
|
}
|
|
374
|
+
catch { }
|
|
362
375
|
}
|
|
376
|
+
}
|
|
377
|
+
// Handle session idle (show summary)
|
|
378
|
+
if (event.type === "session.idle") {
|
|
379
|
+
if (!config.toast.enabled || !config.toast.showOnIdle)
|
|
380
|
+
return;
|
|
381
|
+
const props = event.properties;
|
|
382
|
+
const sessionId = props?.sessionID;
|
|
383
|
+
if (!sessionId)
|
|
384
|
+
return;
|
|
385
|
+
const stats = sessionStats.get(sessionId);
|
|
386
|
+
if (!stats || stats.messageCount === 0)
|
|
387
|
+
return;
|
|
388
|
+
const duration = Math.round((Date.now() - stats.startTime) / 1000 / 60);
|
|
389
|
+
const totalTokens = stats.totalInput + stats.totalOutput;
|
|
363
390
|
try {
|
|
364
391
|
await client.tui.showToast({
|
|
365
392
|
body: {
|
|
366
|
-
title
|
|
367
|
-
message
|
|
368
|
-
variant,
|
|
369
|
-
duration:
|
|
393
|
+
title: `Session: ${formatTokens(totalTokens)} tokens`,
|
|
394
|
+
message: `${formatCost(stats.totalCost)} | ${stats.messageCount} msgs | ${duration}min`,
|
|
395
|
+
variant: "info",
|
|
396
|
+
duration: 5000,
|
|
370
397
|
},
|
|
371
398
|
});
|
|
372
399
|
}
|
|
373
400
|
catch { }
|
|
374
401
|
}
|
|
375
402
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return;
|
|
387
|
-
const duration = Math.round((Date.now() - stats.startTime) / 1000 / 60);
|
|
388
|
-
const totalTokens = stats.totalInput + stats.totalOutput;
|
|
389
|
-
try {
|
|
390
|
-
await client.tui.showToast({
|
|
391
|
-
body: {
|
|
392
|
-
title: `Session: ${formatTokens(totalTokens)} tokens`,
|
|
393
|
-
message: `${formatCost(stats.totalCost)} | ${stats.messageCount} msgs | ${duration}min`,
|
|
394
|
-
variant: "info",
|
|
395
|
-
duration: 5000,
|
|
396
|
-
},
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
catch { }
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
catch { }
|
|
403
|
-
},
|
|
404
|
-
};
|
|
403
|
+
catch { }
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
console.error("[Token Tracker] Initialization failed:", err);
|
|
409
|
+
return {
|
|
410
|
+
event: async () => { },
|
|
411
|
+
};
|
|
412
|
+
}
|
|
405
413
|
};
|
|
406
414
|
export default TokenTrackerPlugin;
|
package/package.json
CHANGED