opencode-token-tracker 1.5.4 → 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.
File without changes
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
+ type ProviderFamily = "anthropic" | "openai" | "deepseek" | "google" | "other";
3
+ export declare function getProviderFamily(model: string, provider: string): ProviderFamily;
4
+ export declare function calculateCost(model: string, provider: string, input: number, output: number, cacheRead?: number, cacheWrite?: number): number;
2
5
  export declare const TokenTrackerPlugin: Plugin;
3
6
  export default TokenTrackerPlugin;
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");
@@ -76,14 +77,59 @@ function getModelPricing(model, provider) {
76
77
  // 5. Fallback to default
77
78
  return BUILTIN_PRICING["_default"];
78
79
  }
79
- function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
80
+ export function getProviderFamily(model, provider) {
81
+ const p = provider.toLowerCase();
82
+ const m = model.toLowerCase();
83
+ if (p.includes("anthropic") || m.startsWith("claude-")) {
84
+ return "anthropic";
85
+ }
86
+ if (p.includes("openai") ||
87
+ m.startsWith("gpt-") ||
88
+ m.startsWith("o1-") ||
89
+ m.startsWith("o3-") ||
90
+ m.startsWith("o4-") ||
91
+ m === "o3" ||
92
+ m === "o1") {
93
+ return "openai";
94
+ }
95
+ if (p.includes("deepseek") || m.includes("deepseek")) {
96
+ return "deepseek";
97
+ }
98
+ if (p.includes("google") || p.includes("vertex") || m.startsWith("gemini-")) {
99
+ return "google";
100
+ }
101
+ return "other";
102
+ }
103
+ export function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
80
104
  const pricing = getModelPricing(model, provider);
105
+ const family = getProviderFamily(model, provider);
106
+ let defaultCacheReadRate = 0.5; // Default 50% discount (OpenAI style)
107
+ let defaultCacheWriteRate = 0; // Default free cache writing
108
+ if (family === "anthropic") {
109
+ defaultCacheReadRate = 0.1;
110
+ defaultCacheWriteRate = 1.25;
111
+ }
112
+ else if (family === "deepseek" || family === "google") {
113
+ defaultCacheReadRate = 0.1;
114
+ defaultCacheWriteRate = 0;
115
+ }
116
+ else if (family === "openai") {
117
+ defaultCacheReadRate = 0.5;
118
+ defaultCacheWriteRate = 0;
119
+ }
120
+ else {
121
+ // "other" / general default
122
+ defaultCacheReadRate = 0.5;
123
+ defaultCacheWriteRate = 0;
124
+ }
125
+ const finalCacheReadPrice = pricing.cacheRead ?? (pricing.input * defaultCacheReadRate);
126
+ const finalCacheWritePrice = pricing.cacheWrite ?? (pricing.input * defaultCacheWriteRate);
81
127
  // Billable input = total input - cache read (cached tokens are charged at cache rate)
82
128
  const billableInput = Math.max(0, input - cacheRead);
83
129
  const inputCost = (billableInput / 1_000_000) * pricing.input;
84
130
  const outputCost = (output / 1_000_000) * pricing.output;
85
- const cacheReadCost = (cacheRead / 1_000_000) * (pricing.cacheRead ?? pricing.input * 0.1);
86
- const cacheWriteCost = (cacheWrite / 1_000_000) * (pricing.cacheWrite ?? pricing.input * 1.25);
131
+ const cacheReadCost = (cacheRead / 1_000_000) * finalCacheReadPrice;
132
+ const cacheWriteCost = (cacheWrite / 1_000_000) * finalCacheWritePrice;
87
133
  return inputCost + outputCost + cacheReadCost + cacheWriteCost;
88
134
  }
89
135
  const sessionStats = new Map();
@@ -146,29 +192,74 @@ const budgetTracker = {
146
192
  function loadCostsSince(since) {
147
193
  if (!existsSync(LOG_FILE))
148
194
  return 0;
195
+ let total = 0;
196
+ let fd = null;
149
197
  try {
150
- const content = readFileSync(LOG_FILE, "utf-8");
151
- const lines = content.trim().split("\n").filter(Boolean);
152
- let total = 0;
153
- 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()) {
154
236
  try {
155
- const entry = JSON.parse(line);
156
- 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) {
157
239
  total += entry.cost;
158
240
  }
159
241
  }
160
242
  catch { }
161
243
  }
162
- return total;
163
244
  }
164
245
  catch {
165
- return 0;
246
+ // 异常路径下放弃部分累加结果,与 1.5.5 之前的语义保持一致,避免下游基于偏小值做预算判断
247
+ total = 0;
166
248
  }
249
+ finally {
250
+ if (fd !== null) {
251
+ try {
252
+ closeSync(fd);
253
+ }
254
+ catch { }
255
+ }
256
+ }
257
+ return total;
167
258
  }
168
259
  /**
169
260
  * Initialize budgetTracker from JSONL file (called once at plugin init).
170
261
  */
171
- function initBudgetTracker() {
262
+ async function initBudgetTracker() {
172
263
  const now = new Date();
173
264
  budgetTracker.dayStart = getStartOfDay(now);
174
265
  budgetTracker.weekStart = getStartOfWeek(now);
@@ -180,28 +271,69 @@ function initBudgetTracker() {
180
271
  return;
181
272
  }
182
273
  // Load once using the earliest period boundary
183
- 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);
184
275
  if (!existsSync(LOG_FILE)) {
185
276
  budgetTracker.initialized = true;
186
277
  return;
187
278
  }
279
+ let fileHandle = null;
188
280
  try {
189
- const content = readFileSync(LOG_FILE, "utf-8");
190
- 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;
191
289
  let daily = 0;
192
290
  let weekly = 0;
193
291
  let monthly = 0;
194
- for (const line of lines) {
195
- try {
196
- const entry = JSON.parse(line);
197
- 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)
198
304
  continue;
199
- if (entry._ts >= budgetTracker.dayStart)
200
- daily += entry.cost;
201
- if (entry._ts >= budgetTracker.weekStart)
202
- weekly += entry.cost;
203
- if (entry._ts >= budgetTracker.monthStart)
204
- 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
+ }
205
337
  }
206
338
  catch { }
207
339
  }
@@ -209,7 +341,17 @@ function initBudgetTracker() {
209
341
  budgetTracker.weeklySpent = weekly;
210
342
  budgetTracker.monthlySpent = monthly;
211
343
  }
212
- 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
+ }
213
355
  budgetTracker.initialized = true;
214
356
  }
215
357
  /**
@@ -297,8 +439,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
297
439
  lastConfigMtime = statSync(CONFIG_FILE).mtimeMs;
298
440
  }
299
441
  // Initialize in-memory budget tracker (reads JSONL once)
300
- initBudgetTracker();
301
- logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
442
+ await initBudgetTracker();
443
+ // 不再写 type:"init" 标记:OpenCode 会在多个子进程(LSP、工具 runner 等)独立加载
444
+ // plugin,每次启动会向 JSONL 写多份重复的 init 行,污染日志且无计费价值。
445
+ // 跨进程去重的根因修复留待后续版本。
302
446
  // Show config validation warnings via Toast
303
447
  if (configWarnings.length > 0) {
304
448
  try {
@@ -323,6 +467,14 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
323
467
  const info = props?.info;
324
468
  if (!info?.tokens)
325
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
+ }
326
478
  const messageId = info.id;
327
479
  const sessionId = info.sessionID;
328
480
  if (!messageId || !sessionId)
@@ -332,10 +484,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
332
484
  const reasoning = info.tokens.reasoning ?? 0;
333
485
  const cacheRead = info.tokens.cache?.read ?? 0;
334
486
  const cacheWrite = info.tokens.cache?.write ?? 0;
335
- const hasTokens = input > 0 || output > 0;
487
+ const hasTokens = input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0;
336
488
  if (!hasTokens)
337
489
  return;
338
- const dedupeKey = `${messageId}-${input}-${output}`;
490
+ const dedupeKey = `${messageId}-${input}-${output}-${cacheRead}-${cacheWrite}`;
339
491
  if (isDuplicate(dedupeKey))
340
492
  return;
341
493
  const model = info.model?.modelID ?? info.modelID ?? "unknown";
@@ -1,6 +1,7 @@
1
1
  import { describe, it } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
3
  import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig, } from "../lib/shared.js";
4
+ import { getProviderFamily, calculateCost } from "../index.js";
4
5
  // ============================================================================
5
6
  // formatCost
6
7
  // ============================================================================
@@ -376,3 +377,81 @@ describe("findModelConfigPricing", () => {
376
377
  assert.deepEqual(findModelConfigPricing(result.config.models, "prefix/my-model", "any-provider"), { input: 1, output: 2 });
377
378
  });
378
379
  });
380
+ // ============================================================================
381
+ // getProviderFamily
382
+ // ============================================================================
383
+ describe("getProviderFamily", () => {
384
+ it("should detect anthropic for various combinations", () => {
385
+ assert.equal(getProviderFamily("claude-opus-4.6", "openai"), "anthropic");
386
+ assert.equal(getProviderFamily("some-model", "anthropic"), "anthropic");
387
+ assert.equal(getProviderFamily("claude-3-5-sonnet", "openrouter"), "anthropic");
388
+ });
389
+ it("should detect openai for various combinations", () => {
390
+ assert.equal(getProviderFamily("gpt-4o", "unknown"), "openai");
391
+ assert.equal(getProviderFamily("o1-mini", "openrouter"), "openai");
392
+ assert.equal(getProviderFamily("o3", "together"), "openai");
393
+ assert.equal(getProviderFamily("some-model", "openai"), "openai");
394
+ });
395
+ it("should detect deepseek for various combinations", () => {
396
+ assert.equal(getProviderFamily("deepseek-chat", "siliconflow"), "deepseek");
397
+ assert.equal(getProviderFamily("deepseek-reasoner", "deepseek"), "deepseek");
398
+ assert.equal(getProviderFamily("deepseek/deepseek-r1", "openrouter"), "deepseek");
399
+ });
400
+ it("should detect google for various combinations", () => {
401
+ assert.equal(getProviderFamily("gemini-2.5-pro", "openrouter"), "google");
402
+ assert.equal(getProviderFamily("some-model", "google"), "google");
403
+ assert.equal(getProviderFamily("gemini-2.0-flash", "vertex"), "google");
404
+ });
405
+ it("should fallback to other for unknown providers/models", () => {
406
+ assert.equal(getProviderFamily("meta-llama-3-8b", "together"), "other");
407
+ assert.equal(getProviderFamily("unknown", "unknown"), "other");
408
+ });
409
+ });
410
+ // ============================================================================
411
+ // calculateCost (provider-specific defaults verification)
412
+ // ============================================================================
413
+ describe("calculateCost", () => {
414
+ it("should apply Anthropic defaults correctly (cacheRead = 10%, cacheWrite = 125%)", () => {
415
+ // claude-sonnet-4 base: input = 3, output = 15.
416
+ // 1M inputs, 1M outputs, 100K cacheRead, 100K cacheWrite
417
+ const cost = calculateCost("claude-sonnet-4", "anthropic", 1_000_000, 1_000_000, 100_000, 100_000);
418
+ // Expected details:
419
+ // billable input = 1M - 100K = 900K tokens = 0.9 * 3.00 = $2.70
420
+ // output = 1M tokens = 1.0 * 15.00 = $15.00
421
+ // cacheRead = 100K tokens = 0.1 * 3.00 * 0.1 = $0.03 (10% discount rate)
422
+ // cacheWrite = 100K tokens = 0.1 * 3.00 * 1.25 = $0.375 (125% rate)
423
+ // total = 2.70 + 15.00 + 0.03 + 0.375 = $18.105
424
+ assert.ok(Math.abs(cost - 18.105) < 0.0001, `expected 18.105, got ${cost}`);
425
+ });
426
+ it("should apply OpenAI defaults correctly (cacheRead = 50%, cacheWrite = 0%)", () => {
427
+ // Built-in gpt-4o has input: 2.5, output: 10.
428
+ // 1M inputs, 1M outputs, 100K cacheRead, 100K cacheWrite
429
+ const cost = calculateCost("gpt-4o", "openai", 1_000_000, 1_000_000, 100_000, 100_000);
430
+ // Expected details:
431
+ // billable input = 1M - 100K = 900K tokens = 0.9 * 2.50 = $2.25
432
+ // output = 1M tokens = 1.0 * 10.00 = $10.00
433
+ // cacheRead = 100K tokens = 0.1 * 2.50 * 0.5 = $0.125 (50% discount rate)
434
+ // cacheWrite = 100K tokens = 0.1 * 2.50 * 0 = $0 (free)
435
+ // total = 2.25 + 10.00 + 0.125 + 0 = $12.375
436
+ assert.ok(Math.abs(cost - 12.375) < 0.0001, `expected 12.375, got ${cost}`);
437
+ });
438
+ it("should apply DeepSeek defaults correctly (cacheRead = 10%, cacheWrite = 0%)", () => {
439
+ // deepseek-chat has input: 0.28, output: 0.42. cacheRead: 0.028 (explicit in table)
440
+ const costExplicit = calculateCost("deepseek-chat", "deepseek", 1_000_000, 1_000_000, 100_000, 100_000);
441
+ // billable input = 900K = 0.9 * 0.28 = $0.252
442
+ // output = 1M = 1.0 * 0.42 = $0.42
443
+ // cacheRead = 100K = 0.1 * 0.028 = $0.0028
444
+ // cacheWrite = 100K = 0.1 * 0 = $0
445
+ // total = 0.252 + 0.42 + 0.0028 = $0.6748
446
+ assert.ok(Math.abs(costExplicit - 0.6748) < 0.0001, `expected 0.6748, got ${costExplicit}`);
447
+ // Verify fallback using non-builtin model under deepseek family:
448
+ const costFallback = calculateCost("deepseek-custom", "deepseek", 1_000_000, 1_000_000, 100_000, 100_000);
449
+ // Default base: input = 1, output = 4.
450
+ // billable input = 900K = 0.9 * 1.00 = $0.90
451
+ // output = 1M = 1.0 * 4.00 = $4.00
452
+ // cacheRead = 100K = 0.1 * 1.00 * 0.1 = $0.01 (10% rate fallback)
453
+ // cacheWrite = 100K = 0.1 * 1.00 * 0 = $0 (free cache write)
454
+ // total = 0.90 + 4.00 + 0.01 = $4.91
455
+ assert.ok(Math.abs(costFallback - 4.91) < 0.0001, `expected 4.91, got ${costFallback}`);
456
+ });
457
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-token-tracker",
3
- "version": "1.5.4",
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
+ }