opencode-token-tracker 1.5.5 → 1.5.6
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/index.js +135 -28
- package/package.json +6 -7
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
3
|
+
import { open } from "fs/promises";
|
|
3
4
|
import { join } from "path";
|
|
4
5
|
import { homedir } from "os";
|
|
5
6
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
@@ -191,29 +192,74 @@ const budgetTracker = {
|
|
|
191
192
|
function loadCostsSince(since) {
|
|
192
193
|
if (!existsSync(LOG_FILE))
|
|
193
194
|
return 0;
|
|
195
|
+
let total = 0;
|
|
196
|
+
let fd = null;
|
|
194
197
|
try {
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
fd = openSync(LOG_FILE, "r");
|
|
199
|
+
const stat = statSync(LOG_FILE);
|
|
200
|
+
const fileSize = stat.size;
|
|
201
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
202
|
+
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
203
|
+
let filePos = fileSize;
|
|
204
|
+
let leftover = "";
|
|
205
|
+
let shouldStop = false;
|
|
206
|
+
while (filePos > 0 && !shouldStop) {
|
|
207
|
+
const readLength = Math.min(CHUNK_SIZE, filePos);
|
|
208
|
+
filePos -= readLength;
|
|
209
|
+
readSync(fd, buffer, 0, readLength, filePos);
|
|
210
|
+
const chunkStr = buffer.toString("utf8", 0, readLength) + leftover;
|
|
211
|
+
const lines = chunkStr.split("\n");
|
|
212
|
+
// The leftmost line could be cut off, save it for the next chunk read to the left
|
|
213
|
+
leftover = lines[0];
|
|
214
|
+
// Iterate lines in reverse order (from end to start)
|
|
215
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
216
|
+
const line = lines[i].trim();
|
|
217
|
+
if (!line)
|
|
218
|
+
continue;
|
|
219
|
+
try {
|
|
220
|
+
const entry = JSON.parse(line);
|
|
221
|
+
if (entry.type !== "tokens" || !entry.cost)
|
|
222
|
+
continue;
|
|
223
|
+
if (entry._ts < since) {
|
|
224
|
+
shouldStop = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
total += entry.cost;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Skip malformed lines
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Include the very first line at the top
|
|
235
|
+
if (!shouldStop && leftover.trim()) {
|
|
199
236
|
try {
|
|
200
|
-
const entry = JSON.parse(
|
|
201
|
-
if (entry.type === "tokens" && entry._ts >= since
|
|
237
|
+
const entry = JSON.parse(leftover.trim());
|
|
238
|
+
if (entry.type === "tokens" && entry.cost && entry._ts >= since) {
|
|
202
239
|
total += entry.cost;
|
|
203
240
|
}
|
|
204
241
|
}
|
|
205
242
|
catch { }
|
|
206
243
|
}
|
|
207
|
-
return total;
|
|
208
244
|
}
|
|
209
245
|
catch {
|
|
210
|
-
|
|
246
|
+
// 异常路径下放弃部分累加结果,与 1.5.5 之前的语义保持一致,避免下游基于偏小值做预算判断
|
|
247
|
+
total = 0;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
if (fd !== null) {
|
|
251
|
+
try {
|
|
252
|
+
closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
catch { }
|
|
255
|
+
}
|
|
211
256
|
}
|
|
257
|
+
return total;
|
|
212
258
|
}
|
|
213
259
|
/**
|
|
214
260
|
* Initialize budgetTracker from JSONL file (called once at plugin init).
|
|
215
261
|
*/
|
|
216
|
-
function initBudgetTracker() {
|
|
262
|
+
async function initBudgetTracker() {
|
|
217
263
|
const now = new Date();
|
|
218
264
|
budgetTracker.dayStart = getStartOfDay(now);
|
|
219
265
|
budgetTracker.weekStart = getStartOfWeek(now);
|
|
@@ -225,28 +271,69 @@ function initBudgetTracker() {
|
|
|
225
271
|
return;
|
|
226
272
|
}
|
|
227
273
|
// Load once using the earliest period boundary
|
|
228
|
-
const earliest = Math.min(budgetTracker.dayStart, budgetTracker.weekStart, budgetTracker.monthStart);
|
|
274
|
+
const earliest = Math.min(budget.daily ? budgetTracker.dayStart : Infinity, budget.weekly ? budgetTracker.weekStart : Infinity, budget.monthly ? budgetTracker.monthStart : Infinity);
|
|
229
275
|
if (!existsSync(LOG_FILE)) {
|
|
230
276
|
budgetTracker.initialized = true;
|
|
231
277
|
return;
|
|
232
278
|
}
|
|
279
|
+
let fileHandle = null;
|
|
233
280
|
try {
|
|
234
|
-
const
|
|
235
|
-
const
|
|
281
|
+
const stat = statSync(LOG_FILE);
|
|
282
|
+
const fileSize = stat.size;
|
|
283
|
+
fileHandle = await open(LOG_FILE, "r");
|
|
284
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
285
|
+
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
286
|
+
let filePos = fileSize;
|
|
287
|
+
let leftover = "";
|
|
288
|
+
let shouldStop = false;
|
|
236
289
|
let daily = 0;
|
|
237
290
|
let weekly = 0;
|
|
238
291
|
let monthly = 0;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
292
|
+
while (filePos > 0 && !shouldStop) {
|
|
293
|
+
const readLength = Math.min(CHUNK_SIZE, filePos);
|
|
294
|
+
filePos -= readLength;
|
|
295
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, readLength, filePos);
|
|
296
|
+
const chunkStr = buffer.toString("utf8", 0, bytesRead) + leftover;
|
|
297
|
+
const lines = chunkStr.split("\n");
|
|
298
|
+
// The leftmost line could be cut off, save it for the next chunk read to the left
|
|
299
|
+
leftover = lines[0];
|
|
300
|
+
// Iterate lines in reverse order (from end to start)
|
|
301
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
302
|
+
const line = lines[i].trim();
|
|
303
|
+
if (!line)
|
|
243
304
|
continue;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
305
|
+
try {
|
|
306
|
+
const entry = JSON.parse(line);
|
|
307
|
+
if (entry.type !== "tokens" || !entry.cost)
|
|
308
|
+
continue;
|
|
309
|
+
if (entry._ts < earliest) {
|
|
310
|
+
shouldStop = true;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
if (entry._ts >= budgetTracker.dayStart)
|
|
314
|
+
daily += entry.cost;
|
|
315
|
+
if (entry._ts >= budgetTracker.weekStart)
|
|
316
|
+
weekly += entry.cost;
|
|
317
|
+
if (entry._ts >= budgetTracker.monthStart)
|
|
318
|
+
monthly += entry.cost;
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// Skip malformed lines
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Include the very first line at the top
|
|
326
|
+
if (!shouldStop && leftover.trim()) {
|
|
327
|
+
try {
|
|
328
|
+
const entry = JSON.parse(leftover.trim());
|
|
329
|
+
if (entry.type === "tokens" && entry.cost && entry._ts >= earliest) {
|
|
330
|
+
if (entry._ts >= budgetTracker.dayStart)
|
|
331
|
+
daily += entry.cost;
|
|
332
|
+
if (entry._ts >= budgetTracker.weekStart)
|
|
333
|
+
weekly += entry.cost;
|
|
334
|
+
if (entry._ts >= budgetTracker.monthStart)
|
|
335
|
+
monthly += entry.cost;
|
|
336
|
+
}
|
|
250
337
|
}
|
|
251
338
|
catch { }
|
|
252
339
|
}
|
|
@@ -254,7 +341,17 @@ function initBudgetTracker() {
|
|
|
254
341
|
budgetTracker.weeklySpent = weekly;
|
|
255
342
|
budgetTracker.monthlySpent = monthly;
|
|
256
343
|
}
|
|
257
|
-
catch {
|
|
344
|
+
catch (err) {
|
|
345
|
+
// Keep budgetTracker at 0 on error
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
if (fileHandle) {
|
|
349
|
+
try {
|
|
350
|
+
await fileHandle.close();
|
|
351
|
+
}
|
|
352
|
+
catch { }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
258
355
|
budgetTracker.initialized = true;
|
|
259
356
|
}
|
|
260
357
|
/**
|
|
@@ -342,8 +439,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
342
439
|
lastConfigMtime = statSync(CONFIG_FILE).mtimeMs;
|
|
343
440
|
}
|
|
344
441
|
// Initialize in-memory budget tracker (reads JSONL once)
|
|
345
|
-
initBudgetTracker();
|
|
346
|
-
|
|
442
|
+
await initBudgetTracker();
|
|
443
|
+
// 不再写 type:"init" 标记:OpenCode 会在多个子进程(LSP、工具 runner 等)独立加载
|
|
444
|
+
// plugin,每次启动会向 JSONL 写多份重复的 init 行,污染日志且无计费价值。
|
|
445
|
+
// 跨进程去重的根因修复留待后续版本。
|
|
347
446
|
// Show config validation warnings via Toast
|
|
348
447
|
if (configWarnings.length > 0) {
|
|
349
448
|
try {
|
|
@@ -368,6 +467,14 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
368
467
|
const info = props?.info;
|
|
369
468
|
if (!info?.tokens)
|
|
370
469
|
return;
|
|
470
|
+
// 流式中间态保护:仅在消息真正完结时记账,避免对同一条消息重复计费。
|
|
471
|
+
// 完结信号优先用 time.completed;实证发现 provider 的 finish reason
|
|
472
|
+
// 会在 time.completed 之前一帧出现且此时 tokens 已完整,因此把 finish
|
|
473
|
+
// 也作为有效的完结信号,避免极端断流时漏掉最后一帧。
|
|
474
|
+
// 通过 modelID 识别 AI 生成消息(user 消息无此字段),不再硬编码 role。
|
|
475
|
+
if (info.modelID && !info.time?.completed && !info.finish) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
371
478
|
const messageId = info.id;
|
|
372
479
|
const sessionId = info.sessionID;
|
|
373
480
|
if (!messageId || !sessionId)
|
|
@@ -377,10 +484,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
377
484
|
const reasoning = info.tokens.reasoning ?? 0;
|
|
378
485
|
const cacheRead = info.tokens.cache?.read ?? 0;
|
|
379
486
|
const cacheWrite = info.tokens.cache?.write ?? 0;
|
|
380
|
-
const hasTokens = input > 0 || output > 0;
|
|
487
|
+
const hasTokens = input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0;
|
|
381
488
|
if (!hasTokens)
|
|
382
489
|
return;
|
|
383
|
-
const dedupeKey = `${messageId}-${input}-${output}`;
|
|
490
|
+
const dedupeKey = `${messageId}-${input}-${output}-${cacheRead}-${cacheWrite}`;
|
|
384
491
|
if (isDuplicate(dedupeKey))
|
|
385
492
|
return;
|
|
386
493
|
const model = info.model?.modelID ?? info.modelID ?? "unknown";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-token-tracker",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.6",
|
|
4
4
|
"description": "Real-time token usage and cost tracking plugin for OpenCode with Toast notifications and CLI stats",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,11 +8,6 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"opencode-tokens": "dist/bin/opencode-tokens.js"
|
|
10
10
|
},
|
|
11
|
-
"scripts": {
|
|
12
|
-
"build": "tsc",
|
|
13
|
-
"test": "tsc && node --test dist/test/shared.test.js",
|
|
14
|
-
"prepublishOnly": "npm run build"
|
|
15
|
-
},
|
|
16
11
|
"keywords": [
|
|
17
12
|
"opencode",
|
|
18
13
|
"plugin",
|
|
@@ -51,5 +46,9 @@
|
|
|
51
46
|
],
|
|
52
47
|
"engines": {
|
|
53
48
|
"node": ">=18.0.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc",
|
|
52
|
+
"test": "tsc && node --test dist/test/shared.test.js"
|
|
54
53
|
}
|
|
55
|
-
}
|
|
54
|
+
}
|