opencode-token-tracker 1.5.3 → 1.5.5

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,5 @@
1
1
  import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
5
  const CONFIG_DIR = join(homedir(), ".config", "opencode");
@@ -11,6 +11,8 @@ const LOG_FILE = join(LOG_DIR, "tokens.jsonl");
11
11
  // ============================================================================
12
12
  let config = DEFAULT_CONFIG;
13
13
  let configWarnings = [];
14
+ let lastConfigLoadTime = 0;
15
+ let lastConfigMtime = 0;
14
16
  function loadConfig() {
15
17
  try {
16
18
  if (existsSync(CONFIG_FILE)) {
@@ -27,6 +29,26 @@ function loadConfig() {
27
29
  }
28
30
  return DEFAULT_CONFIG;
29
31
  }
32
+ function ensureLatestConfig() {
33
+ const now = Date.now();
34
+ if (now - lastConfigLoadTime < 2000) {
35
+ return;
36
+ }
37
+ lastConfigLoadTime = now;
38
+ try {
39
+ if (existsSync(CONFIG_FILE)) {
40
+ const stat = statSync(CONFIG_FILE);
41
+ const mtime = stat.mtimeMs;
42
+ if (mtime !== lastConfigMtime) {
43
+ config = loadConfig();
44
+ lastConfigMtime = mtime;
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ // Keep current config on error
50
+ }
51
+ }
30
52
  // ============================================================================
31
53
  // Pricing
32
54
  // ============================================================================
@@ -54,14 +76,59 @@ function getModelPricing(model, provider) {
54
76
  // 5. Fallback to default
55
77
  return BUILTIN_PRICING["_default"];
56
78
  }
57
- function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
79
+ export function getProviderFamily(model, provider) {
80
+ const p = provider.toLowerCase();
81
+ const m = model.toLowerCase();
82
+ if (p.includes("anthropic") || m.startsWith("claude-")) {
83
+ return "anthropic";
84
+ }
85
+ if (p.includes("openai") ||
86
+ m.startsWith("gpt-") ||
87
+ m.startsWith("o1-") ||
88
+ m.startsWith("o3-") ||
89
+ m.startsWith("o4-") ||
90
+ m === "o3" ||
91
+ m === "o1") {
92
+ return "openai";
93
+ }
94
+ if (p.includes("deepseek") || m.includes("deepseek")) {
95
+ return "deepseek";
96
+ }
97
+ if (p.includes("google") || p.includes("vertex") || m.startsWith("gemini-")) {
98
+ return "google";
99
+ }
100
+ return "other";
101
+ }
102
+ export function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
58
103
  const pricing = getModelPricing(model, provider);
104
+ const family = getProviderFamily(model, provider);
105
+ let defaultCacheReadRate = 0.5; // Default 50% discount (OpenAI style)
106
+ let defaultCacheWriteRate = 0; // Default free cache writing
107
+ if (family === "anthropic") {
108
+ defaultCacheReadRate = 0.1;
109
+ defaultCacheWriteRate = 1.25;
110
+ }
111
+ else if (family === "deepseek" || family === "google") {
112
+ defaultCacheReadRate = 0.1;
113
+ defaultCacheWriteRate = 0;
114
+ }
115
+ else if (family === "openai") {
116
+ defaultCacheReadRate = 0.5;
117
+ defaultCacheWriteRate = 0;
118
+ }
119
+ else {
120
+ // "other" / general default
121
+ defaultCacheReadRate = 0.5;
122
+ defaultCacheWriteRate = 0;
123
+ }
124
+ const finalCacheReadPrice = pricing.cacheRead ?? (pricing.input * defaultCacheReadRate);
125
+ const finalCacheWritePrice = pricing.cacheWrite ?? (pricing.input * defaultCacheWriteRate);
59
126
  // Billable input = total input - cache read (cached tokens are charged at cache rate)
60
127
  const billableInput = Math.max(0, input - cacheRead);
61
128
  const inputCost = (billableInput / 1_000_000) * pricing.input;
62
129
  const outputCost = (output / 1_000_000) * pricing.output;
63
- const cacheReadCost = (cacheRead / 1_000_000) * (pricing.cacheRead ?? pricing.input * 0.1);
64
- const cacheWriteCost = (cacheWrite / 1_000_000) * (pricing.cacheWrite ?? pricing.input * 1.25);
130
+ const cacheReadCost = (cacheRead / 1_000_000) * finalCacheReadPrice;
131
+ const cacheWriteCost = (cacheWrite / 1_000_000) * finalCacheWritePrice;
65
132
  return inputCost + outputCost + cacheReadCost + cacheWriteCost;
66
133
  }
67
134
  const sessionStats = new Map();
@@ -270,6 +337,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
270
337
  try {
271
338
  // Load config on plugin init (with validation)
272
339
  config = loadConfig();
340
+ lastConfigLoadTime = Date.now();
341
+ if (existsSync(CONFIG_FILE)) {
342
+ lastConfigMtime = statSync(CONFIG_FILE).mtimeMs;
343
+ }
273
344
  // Initialize in-memory budget tracker (reads JSONL once)
274
345
  initBudgetTracker();
275
346
  logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
@@ -292,6 +363,7 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
292
363
  try {
293
364
  // Handle message updates (token tracking)
294
365
  if (event.type === "message.updated") {
366
+ ensureLatestConfig();
295
367
  const props = event.properties;
296
368
  const info = props?.info;
297
369
  if (!info?.tokens)
@@ -376,6 +448,7 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
376
448
  }
377
449
  // Handle session idle (show summary)
378
450
  if (event.type === "session.idle") {
451
+ ensureLatestConfig();
379
452
  if (!config.toast.enabled || !config.toast.showOnIdle)
380
453
  return;
381
454
  const props = event.properties;
@@ -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.3",
3
+ "version": "1.5.5",
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",