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.
- package/dist/bin/opencode-tokens.js +0 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +77 -4
- package/dist/test/shared.test.js +79 -0
- package/package.json +1 -1
|
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
|
|
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) *
|
|
64
|
-
const cacheWriteCost = (cacheWrite / 1_000_000) *
|
|
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;
|
package/dist/test/shared.test.js
CHANGED
|
@@ -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