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.
Files changed (2) hide show
  1. package/dist/index.js +135 -28
  2. 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
- const content = readFileSync(LOG_FILE, "utf-8");
196
- const lines = content.trim().split("\n").filter(Boolean);
197
- let total = 0;
198
- for (const line of lines) {
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(line);
201
- if (entry.type === "tokens" && entry._ts >= since && entry.cost) {
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
- return 0;
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 content = readFileSync(LOG_FILE, "utf-8");
235
- const lines = content.trim().split("\n").filter(Boolean);
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
- for (const line of lines) {
240
- try {
241
- const entry = JSON.parse(line);
242
- if (entry.type !== "tokens" || !entry.cost || entry._ts < earliest)
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
- if (entry._ts >= budgetTracker.dayStart)
245
- daily += entry.cost;
246
- if (entry._ts >= budgetTracker.weekStart)
247
- weekly += entry.cost;
248
- if (entry._ts >= budgetTracker.monthStart)
249
- monthly += entry.cost;
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
- logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
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.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
+ }