opencode-token-tracker 1.5.5 → 1.5.7

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.
@@ -325,17 +325,29 @@ function cmdModels() {
325
325
  console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)}`);
326
326
  console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
327
327
  for (const { model, provider, count } of sorted) {
328
- let status = "built-in";
328
+ let status;
329
+ // Mirror the runtime pricing resolution order (getModelPricing in index.ts)
329
330
  if (config.providers?.[provider]) {
330
331
  status = "provider cfg";
331
332
  }
332
- else if (findModelConfigPricing(config.models, model, provider)) {
333
+ else if (findModelConfigPricing(config.models, model, provider, false)) {
333
334
  status = "model cfg";
334
335
  }
335
- else if (!BUILTIN_PRICING[model]) {
336
- // Check partial match
337
- const hasMatch = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && model.toLowerCase().includes(k.toLowerCase()));
338
- status = hasMatch ? "built-in" : "default";
336
+ else if (BUILTIN_PRICING[model]) {
337
+ status = "built-in";
338
+ }
339
+ else {
340
+ const modelLower = model.toLowerCase();
341
+ const hasBuiltinPartial = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && modelLower.includes(k.toLowerCase()));
342
+ if (hasBuiltinPartial) {
343
+ status = "built-in";
344
+ }
345
+ else if (findModelConfigPricing(config.models, model, provider, true)) {
346
+ status = "model cfg";
347
+ }
348
+ else {
349
+ status = "default";
350
+ }
339
351
  }
340
352
  console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)}`);
341
353
  }
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");
@@ -57,8 +58,8 @@ function getModelPricing(model, provider) {
57
58
  if (config.providers[provider]) {
58
59
  return config.providers[provider];
59
60
  }
60
- // 2. Check user-defined model pricing
61
- const configuredPricing = findModelConfigPricing(config.models, model, provider);
61
+ // 2. Check user-defined model pricing (exact match only)
62
+ const configuredPricing = findModelConfigPricing(config.models, model, provider, false);
62
63
  if (configuredPricing) {
63
64
  return configuredPricing;
64
65
  }
@@ -68,12 +69,17 @@ function getModelPricing(model, provider) {
68
69
  }
69
70
  // 4. Try partial match in built-in pricing
70
71
  const modelLower = model.toLowerCase();
71
- for (const [key, pricing] of Object.entries(BUILTIN_PRICING)) {
72
+ for (const [key, pricing] of Object.entries(BUILTIN_PRICING).sort(([a], [b]) => b.length - a.length)) {
72
73
  if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
73
74
  return pricing;
74
75
  }
75
76
  }
76
- // 5. Fallback to default
77
+ // 5. Try partial match in user config
78
+ const partialUserPricing = findModelConfigPricing(config.models, model, provider, true);
79
+ if (partialUserPricing) {
80
+ return partialUserPricing;
81
+ }
82
+ // 6. Fallback to default
77
83
  return BUILTIN_PRICING["_default"];
78
84
  }
79
85
  export function getProviderFamily(model, provider) {
@@ -191,29 +197,74 @@ const budgetTracker = {
191
197
  function loadCostsSince(since) {
192
198
  if (!existsSync(LOG_FILE))
193
199
  return 0;
200
+ let total = 0;
201
+ let fd = null;
194
202
  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) {
203
+ fd = openSync(LOG_FILE, "r");
204
+ const stat = statSync(LOG_FILE);
205
+ const fileSize = stat.size;
206
+ const CHUNK_SIZE = 64 * 1024; // 64KB chunks
207
+ const buffer = Buffer.alloc(CHUNK_SIZE);
208
+ let filePos = fileSize;
209
+ let leftover = "";
210
+ let shouldStop = false;
211
+ while (filePos > 0 && !shouldStop) {
212
+ const readLength = Math.min(CHUNK_SIZE, filePos);
213
+ filePos -= readLength;
214
+ readSync(fd, buffer, 0, readLength, filePos);
215
+ const chunkStr = buffer.toString("utf8", 0, readLength) + leftover;
216
+ const lines = chunkStr.split("\n");
217
+ // The leftmost line could be cut off, save it for the next chunk read to the left
218
+ leftover = lines[0];
219
+ // Iterate lines in reverse order (from end to start)
220
+ for (let i = lines.length - 1; i >= 1; i--) {
221
+ const line = lines[i].trim();
222
+ if (!line)
223
+ continue;
224
+ try {
225
+ const entry = JSON.parse(line);
226
+ if (entry.type !== "tokens" || !entry.cost)
227
+ continue;
228
+ if (entry._ts < since) {
229
+ shouldStop = true;
230
+ break;
231
+ }
232
+ total += entry.cost;
233
+ }
234
+ catch {
235
+ // Skip malformed lines
236
+ }
237
+ }
238
+ }
239
+ // Include the very first line at the top
240
+ if (!shouldStop && leftover.trim()) {
199
241
  try {
200
- const entry = JSON.parse(line);
201
- if (entry.type === "tokens" && entry._ts >= since && entry.cost) {
242
+ const entry = JSON.parse(leftover.trim());
243
+ if (entry.type === "tokens" && entry.cost && entry._ts >= since) {
202
244
  total += entry.cost;
203
245
  }
204
246
  }
205
247
  catch { }
206
248
  }
207
- return total;
208
249
  }
209
250
  catch {
210
- return 0;
251
+ // 异常路径下放弃部分累加结果,与 1.5.5 之前的语义保持一致,避免下游基于偏小值做预算判断
252
+ total = 0;
253
+ }
254
+ finally {
255
+ if (fd !== null) {
256
+ try {
257
+ closeSync(fd);
258
+ }
259
+ catch { }
260
+ }
211
261
  }
262
+ return total;
212
263
  }
213
264
  /**
214
265
  * Initialize budgetTracker from JSONL file (called once at plugin init).
215
266
  */
216
- function initBudgetTracker() {
267
+ async function initBudgetTracker() {
217
268
  const now = new Date();
218
269
  budgetTracker.dayStart = getStartOfDay(now);
219
270
  budgetTracker.weekStart = getStartOfWeek(now);
@@ -225,28 +276,69 @@ function initBudgetTracker() {
225
276
  return;
226
277
  }
227
278
  // Load once using the earliest period boundary
228
- const earliest = Math.min(budgetTracker.dayStart, budgetTracker.weekStart, budgetTracker.monthStart);
279
+ const earliest = Math.min(budget.daily ? budgetTracker.dayStart : Infinity, budget.weekly ? budgetTracker.weekStart : Infinity, budget.monthly ? budgetTracker.monthStart : Infinity);
229
280
  if (!existsSync(LOG_FILE)) {
230
281
  budgetTracker.initialized = true;
231
282
  return;
232
283
  }
284
+ let fileHandle = null;
233
285
  try {
234
- const content = readFileSync(LOG_FILE, "utf-8");
235
- const lines = content.trim().split("\n").filter(Boolean);
286
+ const stat = statSync(LOG_FILE);
287
+ const fileSize = stat.size;
288
+ fileHandle = await open(LOG_FILE, "r");
289
+ const CHUNK_SIZE = 64 * 1024; // 64KB chunks
290
+ const buffer = Buffer.alloc(CHUNK_SIZE);
291
+ let filePos = fileSize;
292
+ let leftover = "";
293
+ let shouldStop = false;
236
294
  let daily = 0;
237
295
  let weekly = 0;
238
296
  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)
297
+ while (filePos > 0 && !shouldStop) {
298
+ const readLength = Math.min(CHUNK_SIZE, filePos);
299
+ filePos -= readLength;
300
+ const { bytesRead } = await fileHandle.read(buffer, 0, readLength, filePos);
301
+ const chunkStr = buffer.toString("utf8", 0, bytesRead) + leftover;
302
+ const lines = chunkStr.split("\n");
303
+ // The leftmost line could be cut off, save it for the next chunk read to the left
304
+ leftover = lines[0];
305
+ // Iterate lines in reverse order (from end to start)
306
+ for (let i = lines.length - 1; i >= 1; i--) {
307
+ const line = lines[i].trim();
308
+ if (!line)
243
309
  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;
310
+ try {
311
+ const entry = JSON.parse(line);
312
+ if (entry.type !== "tokens" || !entry.cost)
313
+ continue;
314
+ if (entry._ts < earliest) {
315
+ shouldStop = true;
316
+ break;
317
+ }
318
+ if (entry._ts >= budgetTracker.dayStart)
319
+ daily += entry.cost;
320
+ if (entry._ts >= budgetTracker.weekStart)
321
+ weekly += entry.cost;
322
+ if (entry._ts >= budgetTracker.monthStart)
323
+ monthly += entry.cost;
324
+ }
325
+ catch {
326
+ // Skip malformed lines
327
+ }
328
+ }
329
+ }
330
+ // Include the very first line at the top
331
+ if (!shouldStop && leftover.trim()) {
332
+ try {
333
+ const entry = JSON.parse(leftover.trim());
334
+ if (entry.type === "tokens" && entry.cost && entry._ts >= earliest) {
335
+ if (entry._ts >= budgetTracker.dayStart)
336
+ daily += entry.cost;
337
+ if (entry._ts >= budgetTracker.weekStart)
338
+ weekly += entry.cost;
339
+ if (entry._ts >= budgetTracker.monthStart)
340
+ monthly += entry.cost;
341
+ }
250
342
  }
251
343
  catch { }
252
344
  }
@@ -254,7 +346,17 @@ function initBudgetTracker() {
254
346
  budgetTracker.weeklySpent = weekly;
255
347
  budgetTracker.monthlySpent = monthly;
256
348
  }
257
- catch { }
349
+ catch (err) {
350
+ // Keep budgetTracker at 0 on error
351
+ }
352
+ finally {
353
+ if (fileHandle) {
354
+ try {
355
+ await fileHandle.close();
356
+ }
357
+ catch { }
358
+ }
359
+ }
258
360
  budgetTracker.initialized = true;
259
361
  }
260
362
  /**
@@ -342,8 +444,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
342
444
  lastConfigMtime = statSync(CONFIG_FILE).mtimeMs;
343
445
  }
344
446
  // Initialize in-memory budget tracker (reads JSONL once)
345
- initBudgetTracker();
346
- logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
447
+ await initBudgetTracker();
448
+ // 不再写 type:"init" 标记:OpenCode 会在多个子进程(LSP、工具 runner 等)独立加载
449
+ // plugin,每次启动会向 JSONL 写多份重复的 init 行,污染日志且无计费价值。
450
+ // 跨进程去重的根因修复留待后续版本。
347
451
  // Show config validation warnings via Toast
348
452
  if (configWarnings.length > 0) {
349
453
  try {
@@ -368,6 +472,14 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
368
472
  const info = props?.info;
369
473
  if (!info?.tokens)
370
474
  return;
475
+ // 流式中间态保护:仅在消息真正完结时记账,避免对同一条消息重复计费。
476
+ // 完结信号优先用 time.completed;实证发现 provider 的 finish reason
477
+ // 会在 time.completed 之前一帧出现且此时 tokens 已完整,因此把 finish
478
+ // 也作为有效的完结信号,避免极端断流时漏掉最后一帧。
479
+ // 通过 modelID 识别 AI 生成消息(user 消息无此字段),不再硬编码 role。
480
+ if (info.modelID && !info.time?.completed && !info.finish) {
481
+ return;
482
+ }
371
483
  const messageId = info.id;
372
484
  const sessionId = info.sessionID;
373
485
  if (!messageId || !sessionId)
@@ -377,10 +489,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
377
489
  const reasoning = info.tokens.reasoning ?? 0;
378
490
  const cacheRead = info.tokens.cache?.read ?? 0;
379
491
  const cacheWrite = info.tokens.cache?.write ?? 0;
380
- const hasTokens = input > 0 || output > 0;
492
+ const hasTokens = input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0;
381
493
  if (!hasTokens)
382
494
  return;
383
- const dedupeKey = `${messageId}-${input}-${output}`;
495
+ const dedupeKey = `${messageId}-${input}-${output}-${cacheRead}-${cacheWrite}`;
384
496
  if (isDuplicate(dedupeKey))
385
497
  return;
386
498
  const model = info.model?.modelID ?? info.modelID ?? "unknown";
@@ -45,4 +45,4 @@ export interface ConfigValidationResult {
45
45
  * Invalid fields are silently corrected to defaults with warnings.
46
46
  */
47
47
  export declare function validateConfig(raw: unknown): ConfigValidationResult;
48
- export declare function findModelConfigPricing(models: TrackerConfig["models"], model: string, provider: string): ModelPricing | undefined;
48
+ export declare function findModelConfigPricing(models: TrackerConfig["models"], model: string, provider: string, partial?: boolean): ModelPricing | undefined;
@@ -174,6 +174,12 @@ function validatePricingMap(raw, section, warnings, allowProviderModels = false)
174
174
  warnings.push(`${entryPath} should be a pricing object, ignoring`);
175
175
  continue;
176
176
  }
177
+ // If the structure looks like flat pricing (has pricing field keys),
178
+ // don't fall through to nested provider pricing — validatePricingObject
179
+ // already issued the relevant warning for malformed flat pricing.
180
+ if (hasFlatPricingStructure(value)) {
181
+ continue;
182
+ }
177
183
  const providerPricing = validateNestedPricingMap(value, entryPath, warnings);
178
184
  if (Object.keys(providerPricing).length > 0) {
179
185
  result[key] = providerPricing;
@@ -246,11 +252,7 @@ function isPlainObject(value) {
246
252
  return value !== null && typeof value === "object" && !Array.isArray(value);
247
253
  }
248
254
  function isDirectModelPricing(value) {
249
- return hasFlatPricingStructure(value)
250
- && isFiniteNumber(value.input)
251
- && isFiniteNumber(value.output)
252
- && (value.cacheRead === undefined || isFiniteNumber(value.cacheRead))
253
- && (value.cacheWrite === undefined || isFiniteNumber(value.cacheWrite));
255
+ return hasFlatPricingStructure(value);
254
256
  }
255
257
  function resolveModelConfigEntry(entry, provider) {
256
258
  if (!entry)
@@ -259,13 +261,17 @@ function resolveModelConfigEntry(entry, provider) {
259
261
  return entry;
260
262
  return entry[provider];
261
263
  }
262
- export function findModelConfigPricing(models, model, provider) {
264
+ export function findModelConfigPricing(models, model, provider, partial = true) {
263
265
  const exactMatch = resolveModelConfigEntry(models[model], provider);
264
266
  if (exactMatch) {
265
267
  return exactMatch;
266
268
  }
269
+ if (!partial)
270
+ return undefined;
267
271
  const modelLower = model.toLowerCase();
268
- for (const [key, entry] of Object.entries(models)) {
272
+ // Sort by key length descending so longer (more specific) keys are checked first
273
+ const sorted = Object.entries(models).sort(([a], [b]) => b.length - a.length);
274
+ for (const [key, entry] of sorted) {
269
275
  if (modelLower.includes(key.toLowerCase())) {
270
276
  const partialMatch = resolveModelConfigEntry(entry, provider);
271
277
  if (partialMatch) {
@@ -237,6 +237,16 @@ describe("validateConfig", () => {
237
237
  assert.equal(result.config.models["not-object"], undefined);
238
238
  assert.ok(result.warnings.length >= 4);
239
239
  });
240
+ it("should produce one clear warning for malformed flat pricing (not nested fallthrough noise)", () => {
241
+ const result = validateConfig({
242
+ models: {
243
+ "my-model": { input: "free", output: 2 },
244
+ },
245
+ });
246
+ assert.equal(result.config.models["my-model"], undefined);
247
+ assert.equal(result.warnings.length, 1);
248
+ assert.ok(result.warnings[0].includes("input should be a non-negative number"));
249
+ });
240
250
  it("should warn and ignore invalid cacheRead/cacheWrite but keep entry", () => {
241
251
  const result = validateConfig({
242
252
  models: {
@@ -376,6 +386,40 @@ describe("findModelConfigPricing", () => {
376
386
  });
377
387
  assert.deepEqual(findModelConfigPricing(result.config.models, "prefix/my-model", "any-provider"), { input: 1, output: 2 });
378
388
  });
389
+ it("should prefer longer key over shorter key in partial matches (longest-first)", () => {
390
+ const result = validateConfig({
391
+ models: {
392
+ "gpt-4.1": { input: 3, output: 12 },
393
+ "gpt-4.1-mini": { input: 0.8, output: 3.2 },
394
+ },
395
+ });
396
+ assert.deepEqual(findModelConfigPricing(result.config.models, "gpt-4.1-mini-2025", "openai"), { input: 0.8, output: 3.2 });
397
+ });
398
+ it("should prefer longer key regardless of insertion order (longest-first)", () => {
399
+ const result = validateConfig({
400
+ models: {
401
+ "gpt-4.1-mini": { input: 0.8, output: 3.2 },
402
+ "gpt-4.1": { input: 3, output: 12 },
403
+ },
404
+ });
405
+ assert.deepEqual(findModelConfigPricing(result.config.models, "gpt-4.1-mini-2025", "openai"), { input: 0.8, output: 3.2 });
406
+ });
407
+ it("should not match partial keys when partial=false (exact-only mode)", () => {
408
+ const result = validateConfig({
409
+ models: {
410
+ "claude": { input: 0, output: 0 },
411
+ },
412
+ });
413
+ assert.equal(findModelConfigPricing(result.config.models, "claude-opus-4.6", "anthropic", false), undefined);
414
+ });
415
+ it("should match partial keys when partial=true (backward compatible)", () => {
416
+ const result = validateConfig({
417
+ models: {
418
+ "claude": { input: 0, output: 0 },
419
+ },
420
+ });
421
+ assert.deepEqual(findModelConfigPricing(result.config.models, "claude-opus-4.6", "anthropic", true), { input: 0, output: 0 });
422
+ });
379
423
  });
380
424
  // ============================================================================
381
425
  // getProviderFamily
@@ -455,3 +499,38 @@ describe("calculateCost", () => {
455
499
  assert.ok(Math.abs(costFallback - 4.91) < 0.0001, `expected 4.91, got ${costFallback}`);
456
500
  });
457
501
  });
502
+ // ============================================================================
503
+ // calculateCost partial match — longest-key-first regression
504
+ // ============================================================================
505
+ describe("calculateCost partial match (longest-key-first)", () => {
506
+ it("should match gpt-4o-mini over gpt-4o for variant model name", () => {
507
+ // gpt-4o-mini-2024-07-18 should match gpt-4o-mini ($0.15/$0.6), not gpt-4o ($2.5/$10)
508
+ const cost = calculateCost("gpt-4o-mini-2024-07-18", "openai", 1_000_000, 1_000_000);
509
+ // Expected: input 0.15 + output 0.6 = 0.75 (no cache in this test)
510
+ assert.ok(Math.abs(cost - 0.75) < 0.001, `expected 0.75, got ${cost}`);
511
+ });
512
+ it("should match o3-mini over o3 for variant model name", () => {
513
+ // o3-mini-high should match o3-mini ($1.1/$4.4), not o3 ($10/$40)
514
+ const cost = calculateCost("o3-mini-high", "openai", 1_000_000, 1_000_000);
515
+ // Expected: input 1.1 + output 4.4 = 5.5
516
+ assert.ok(Math.abs(cost - 5.5) < 0.001, `expected 5.5, got ${cost}`);
517
+ });
518
+ it("should match gemini-2.5-flash-lite over gemini-2.5-flash for variant model name", () => {
519
+ // gemini-2.5-flash-lite-preview should match gemini-2.5-flash-lite ($0.1/$0.4), not gemini-2.5-flash ($0.3/$2.5)
520
+ const cost = calculateCost("gemini-2.5-flash-lite-preview", "google", 1_000_000, 1_000_000);
521
+ // Expected: input 0.1 + output 0.4 = 0.5
522
+ assert.ok(Math.abs(cost - 0.5) < 0.001, `expected 0.5, got ${cost}`);
523
+ });
524
+ it("should match gpt-5.2-pro over gpt-5.2 for variant model name", () => {
525
+ // gpt-5.2-pro-2025 should match gpt-5.2-pro ($21/$168), not gpt-5.2 ($1.75/$14)
526
+ const cost = calculateCost("gpt-5.2-pro-2025", "openai", 1_000_000, 1_000_000);
527
+ // Expected: input 21 + output 168 = 189
528
+ assert.ok(Math.abs(cost - 189) < 0.01, `expected 189, got ${cost}`);
529
+ });
530
+ it("should match o1-mini over o1 for variant model name", () => {
531
+ // o1-mini-high should match o1-mini ($1.1/$4.4), not o1 ($15/$60)
532
+ const cost = calculateCost("o1-mini-high", "openai", 1_000_000, 1_000_000);
533
+ // Expected: input 1.1 + output 4.4 = 5.5
534
+ assert.ok(Math.abs(cost - 5.5) < 0.001, `expected 5.5, got ${cost}`);
535
+ });
536
+ });
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.7",
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",