opencode-token-tracker 1.4.0 → 1.5.0
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/README.md +10 -1
- package/README.zh-CN.md +367 -0
- package/dist/bin/opencode-tokens.js +29 -93
- package/dist/index.js +131 -137
- package/dist/lib/shared.d.ts +44 -0
- package/dist/lib/shared.js +279 -0
- package/dist/test/shared.test.d.ts +1 -0
- package/dist/test/shared.test.js +285 -0
- package/package.json +2 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { BUILTIN_PRICING, DEFAULT_CONFIG, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig, } from "../lib/shared.js";
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// formatCost
|
|
6
|
+
// ============================================================================
|
|
7
|
+
describe("formatCost", () => {
|
|
8
|
+
it("should format tiny costs with 4 decimals", () => {
|
|
9
|
+
assert.equal(formatCost(0.001), "$0.0010");
|
|
10
|
+
assert.equal(formatCost(0.0099), "$0.0099");
|
|
11
|
+
assert.equal(formatCost(0), "$0.0000");
|
|
12
|
+
});
|
|
13
|
+
it("should format sub-dollar costs with 3 decimals", () => {
|
|
14
|
+
assert.equal(formatCost(0.01), "$0.010");
|
|
15
|
+
assert.equal(formatCost(0.123), "$0.123");
|
|
16
|
+
assert.equal(formatCost(0.999), "$0.999");
|
|
17
|
+
});
|
|
18
|
+
it("should format dollar+ costs with 2 decimals", () => {
|
|
19
|
+
assert.equal(formatCost(1), "$1.00");
|
|
20
|
+
assert.equal(formatCost(12.345), "$12.35");
|
|
21
|
+
assert.equal(formatCost(100), "$100.00");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// formatTokens
|
|
26
|
+
// ============================================================================
|
|
27
|
+
describe("formatTokens", () => {
|
|
28
|
+
it("should format raw numbers below 1K", () => {
|
|
29
|
+
assert.equal(formatTokens(0), "0");
|
|
30
|
+
assert.equal(formatTokens(999), "999");
|
|
31
|
+
});
|
|
32
|
+
it("should format K-level with 1 decimal", () => {
|
|
33
|
+
assert.equal(formatTokens(1_000), "1.0K");
|
|
34
|
+
assert.equal(formatTokens(1_500), "1.5K");
|
|
35
|
+
assert.equal(formatTokens(999_999), "1000.0K");
|
|
36
|
+
});
|
|
37
|
+
it("should format M-level with default 1 decimal", () => {
|
|
38
|
+
assert.equal(formatTokens(1_000_000), "1.0M");
|
|
39
|
+
assert.equal(formatTokens(1_500_000), "1.5M");
|
|
40
|
+
assert.equal(formatTokens(12_345_678), "12.3M");
|
|
41
|
+
});
|
|
42
|
+
it("should respect millionDecimals parameter", () => {
|
|
43
|
+
assert.equal(formatTokens(1_234_567, 0), "1M");
|
|
44
|
+
assert.equal(formatTokens(1_234_567, 2), "1.23M");
|
|
45
|
+
assert.equal(formatTokens(1_234_567, 3), "1.235M");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// getStartOfDay
|
|
50
|
+
// ============================================================================
|
|
51
|
+
describe("getStartOfDay", () => {
|
|
52
|
+
it("should return midnight of the given date", () => {
|
|
53
|
+
const date = new Date(2026, 1, 11, 14, 30, 45); // Feb 11, 2026 14:30:45
|
|
54
|
+
const start = getStartOfDay(date);
|
|
55
|
+
const result = new Date(start);
|
|
56
|
+
assert.equal(result.getHours(), 0);
|
|
57
|
+
assert.equal(result.getMinutes(), 0);
|
|
58
|
+
assert.equal(result.getSeconds(), 0);
|
|
59
|
+
assert.equal(result.getMilliseconds(), 0);
|
|
60
|
+
assert.equal(result.getDate(), 11);
|
|
61
|
+
assert.equal(result.getMonth(), 1); // February
|
|
62
|
+
});
|
|
63
|
+
it("should default to today when no argument", () => {
|
|
64
|
+
const start = getStartOfDay();
|
|
65
|
+
const result = new Date(start);
|
|
66
|
+
const now = new Date();
|
|
67
|
+
assert.equal(result.getFullYear(), now.getFullYear());
|
|
68
|
+
assert.equal(result.getMonth(), now.getMonth());
|
|
69
|
+
assert.equal(result.getDate(), now.getDate());
|
|
70
|
+
assert.equal(result.getHours(), 0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// getStartOfWeek
|
|
75
|
+
// ============================================================================
|
|
76
|
+
describe("getStartOfWeek", () => {
|
|
77
|
+
it("should return Monday 00:00 for a mid-week date", () => {
|
|
78
|
+
// Wednesday Feb 11, 2026
|
|
79
|
+
const date = new Date(2026, 1, 11, 10, 0, 0);
|
|
80
|
+
const start = getStartOfWeek(date);
|
|
81
|
+
const result = new Date(start);
|
|
82
|
+
assert.equal(result.getDay(), 1); // Monday
|
|
83
|
+
assert.equal(result.getDate(), 9); // Feb 9, 2026 is Monday
|
|
84
|
+
assert.equal(result.getHours(), 0);
|
|
85
|
+
});
|
|
86
|
+
it("should handle Sunday correctly (go back to previous Monday)", () => {
|
|
87
|
+
// Sunday Feb 15, 2026
|
|
88
|
+
const date = new Date(2026, 1, 15, 10, 0, 0);
|
|
89
|
+
const start = getStartOfWeek(date);
|
|
90
|
+
const result = new Date(start);
|
|
91
|
+
assert.equal(result.getDay(), 1); // Monday
|
|
92
|
+
assert.equal(result.getDate(), 9); // Feb 9 is the Monday of that week
|
|
93
|
+
});
|
|
94
|
+
it("should handle Monday correctly (same day)", () => {
|
|
95
|
+
// Monday Feb 9, 2026
|
|
96
|
+
const date = new Date(2026, 1, 9, 15, 30, 0);
|
|
97
|
+
const start = getStartOfWeek(date);
|
|
98
|
+
const result = new Date(start);
|
|
99
|
+
assert.equal(result.getDay(), 1);
|
|
100
|
+
assert.equal(result.getDate(), 9);
|
|
101
|
+
assert.equal(result.getHours(), 0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// getStartOfMonth
|
|
106
|
+
// ============================================================================
|
|
107
|
+
describe("getStartOfMonth", () => {
|
|
108
|
+
it("should return the 1st of the month at midnight", () => {
|
|
109
|
+
const date = new Date(2026, 1, 15, 12, 0, 0); // Feb 15
|
|
110
|
+
const start = getStartOfMonth(date);
|
|
111
|
+
const result = new Date(start);
|
|
112
|
+
assert.equal(result.getDate(), 1);
|
|
113
|
+
assert.equal(result.getMonth(), 1);
|
|
114
|
+
assert.equal(result.getHours(), 0);
|
|
115
|
+
assert.equal(result.getMinutes(), 0);
|
|
116
|
+
});
|
|
117
|
+
it("should handle first day of month", () => {
|
|
118
|
+
const date = new Date(2026, 0, 1, 23, 59, 59); // Jan 1
|
|
119
|
+
const start = getStartOfMonth(date);
|
|
120
|
+
const result = new Date(start);
|
|
121
|
+
assert.equal(result.getDate(), 1);
|
|
122
|
+
assert.equal(result.getMonth(), 0);
|
|
123
|
+
assert.equal(result.getHours(), 0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// BUILTIN_PRICING integrity
|
|
128
|
+
// ============================================================================
|
|
129
|
+
describe("BUILTIN_PRICING", () => {
|
|
130
|
+
it("should contain _default entry", () => {
|
|
131
|
+
assert.ok(BUILTIN_PRICING["_default"]);
|
|
132
|
+
assert.equal(typeof BUILTIN_PRICING["_default"].input, "number");
|
|
133
|
+
assert.equal(typeof BUILTIN_PRICING["_default"].output, "number");
|
|
134
|
+
});
|
|
135
|
+
it("should have positive input/output for all entries", () => {
|
|
136
|
+
for (const [model, pricing] of Object.entries(BUILTIN_PRICING)) {
|
|
137
|
+
assert.ok(pricing.input >= 0, `${model}.input should be >= 0`);
|
|
138
|
+
assert.ok(pricing.output >= 0, `${model}.output should be >= 0`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
it("should have cacheRead <= input when cacheRead is present", () => {
|
|
142
|
+
for (const [model, pricing] of Object.entries(BUILTIN_PRICING)) {
|
|
143
|
+
if (pricing.cacheRead !== undefined) {
|
|
144
|
+
assert.ok(pricing.cacheRead <= pricing.input, `${model}.cacheRead ($${pricing.cacheRead}) should be <= input ($${pricing.input})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
it("should contain all expected provider groups", () => {
|
|
149
|
+
// Anthropic
|
|
150
|
+
assert.ok(BUILTIN_PRICING["claude-opus-4.6"], "missing claude-opus-4.6");
|
|
151
|
+
assert.ok(BUILTIN_PRICING["claude-sonnet-4.5"], "missing claude-sonnet-4.5");
|
|
152
|
+
assert.ok(BUILTIN_PRICING["claude-haiku-4.5"], "missing claude-haiku-4.5");
|
|
153
|
+
// OpenAI
|
|
154
|
+
assert.ok(BUILTIN_PRICING["gpt-5.2"], "missing gpt-5.2");
|
|
155
|
+
assert.ok(BUILTIN_PRICING["o3"], "missing o3");
|
|
156
|
+
// DeepSeek
|
|
157
|
+
assert.ok(BUILTIN_PRICING["deepseek-chat"], "missing deepseek-chat");
|
|
158
|
+
// Google
|
|
159
|
+
assert.ok(BUILTIN_PRICING["gemini-3-pro"], "missing gemini-3-pro");
|
|
160
|
+
assert.ok(BUILTIN_PRICING["gemini-2.5-flash"], "missing gemini-2.5-flash");
|
|
161
|
+
assert.ok(BUILTIN_PRICING["gemini-2.0-flash-lite"], "missing gemini-2.0-flash-lite");
|
|
162
|
+
});
|
|
163
|
+
it("should have at least 35 model entries (including _default)", () => {
|
|
164
|
+
const count = Object.keys(BUILTIN_PRICING).length;
|
|
165
|
+
assert.ok(count >= 35, `expected >= 35 entries, got ${count}`);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// validateConfig
|
|
170
|
+
// ============================================================================
|
|
171
|
+
describe("validateConfig", () => {
|
|
172
|
+
it("should return defaults for null/undefined/array input", () => {
|
|
173
|
+
for (const input of [null, undefined, [], 42, "string"]) {
|
|
174
|
+
const result = validateConfig(input);
|
|
175
|
+
assert.deepEqual(result.config, DEFAULT_CONFIG);
|
|
176
|
+
assert.ok(result.warnings.length > 0, `expected warnings for input: ${JSON.stringify(input)}`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
it("should return defaults with no warnings for empty object", () => {
|
|
180
|
+
const result = validateConfig({});
|
|
181
|
+
assert.deepEqual(result.config, DEFAULT_CONFIG);
|
|
182
|
+
assert.equal(result.warnings.length, 0);
|
|
183
|
+
});
|
|
184
|
+
it("should accept valid complete config", () => {
|
|
185
|
+
const raw = {
|
|
186
|
+
providers: { "my-provider": { input: 0, output: 0 } },
|
|
187
|
+
models: { "my-model": { input: 1, output: 2, cacheRead: 0.1 } },
|
|
188
|
+
toast: { enabled: false, duration: 5000, showOnIdle: false },
|
|
189
|
+
budget: { daily: 10, weekly: 50, monthly: 200, warnAt: 0.9 },
|
|
190
|
+
};
|
|
191
|
+
const result = validateConfig(raw);
|
|
192
|
+
assert.equal(result.warnings.length, 0);
|
|
193
|
+
assert.deepEqual(result.config.providers, { "my-provider": { input: 0, output: 0 } });
|
|
194
|
+
assert.deepEqual(result.config.models, { "my-model": { input: 1, output: 2, cacheRead: 0.1 } });
|
|
195
|
+
assert.equal(result.config.toast.enabled, false);
|
|
196
|
+
assert.equal(result.config.toast.duration, 5000);
|
|
197
|
+
assert.equal(result.config.budget.daily, 10);
|
|
198
|
+
assert.equal(result.config.budget.warnAt, 0.9);
|
|
199
|
+
});
|
|
200
|
+
it("should warn and ignore non-object providers/models", () => {
|
|
201
|
+
const result = validateConfig({ providers: "invalid", models: 123 });
|
|
202
|
+
assert.deepEqual(result.config.providers, {});
|
|
203
|
+
assert.deepEqual(result.config.models, {});
|
|
204
|
+
assert.ok(result.warnings.some(w => w.includes("providers")));
|
|
205
|
+
assert.ok(result.warnings.some(w => w.includes("models")));
|
|
206
|
+
});
|
|
207
|
+
it("should warn and skip pricing entries with invalid input/output", () => {
|
|
208
|
+
const result = validateConfig({
|
|
209
|
+
models: {
|
|
210
|
+
"good": { input: 1, output: 2 },
|
|
211
|
+
"bad-input": { input: "free", output: 2 },
|
|
212
|
+
"bad-output": { input: 1, output: -5 },
|
|
213
|
+
"missing-input": { output: 2 },
|
|
214
|
+
"not-object": "hello",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
assert.ok(result.config.models["good"]);
|
|
218
|
+
assert.equal(result.config.models["bad-input"], undefined);
|
|
219
|
+
assert.equal(result.config.models["bad-output"], undefined);
|
|
220
|
+
assert.equal(result.config.models["missing-input"], undefined);
|
|
221
|
+
assert.equal(result.config.models["not-object"], undefined);
|
|
222
|
+
assert.ok(result.warnings.length >= 4);
|
|
223
|
+
});
|
|
224
|
+
it("should warn and ignore invalid cacheRead/cacheWrite but keep entry", () => {
|
|
225
|
+
const result = validateConfig({
|
|
226
|
+
models: {
|
|
227
|
+
"m1": { input: 1, output: 2, cacheRead: "bad", cacheWrite: -1 },
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const m1 = result.config.models["m1"];
|
|
231
|
+
assert.ok(m1);
|
|
232
|
+
assert.equal(m1.input, 1);
|
|
233
|
+
assert.equal(m1.output, 2);
|
|
234
|
+
assert.equal(m1.cacheRead, undefined);
|
|
235
|
+
assert.equal(m1.cacheWrite, undefined);
|
|
236
|
+
assert.ok(result.warnings.some(w => w.includes("cacheRead")));
|
|
237
|
+
assert.ok(result.warnings.some(w => w.includes("cacheWrite")));
|
|
238
|
+
});
|
|
239
|
+
it("should warn and use default toast for invalid toast fields", () => {
|
|
240
|
+
const result = validateConfig({
|
|
241
|
+
toast: { enabled: "yes", duration: -100, showOnIdle: 1 },
|
|
242
|
+
});
|
|
243
|
+
assert.equal(result.config.toast.enabled, DEFAULT_CONFIG.toast.enabled);
|
|
244
|
+
assert.equal(result.config.toast.duration, DEFAULT_CONFIG.toast.duration);
|
|
245
|
+
assert.equal(result.config.toast.showOnIdle, DEFAULT_CONFIG.toast.showOnIdle);
|
|
246
|
+
assert.ok(result.warnings.length >= 3);
|
|
247
|
+
});
|
|
248
|
+
it("should warn and use default toast for non-object toast", () => {
|
|
249
|
+
const result = validateConfig({ toast: "invalid" });
|
|
250
|
+
assert.deepEqual(result.config.toast, DEFAULT_CONFIG.toast);
|
|
251
|
+
assert.ok(result.warnings.some(w => w.includes("toast")));
|
|
252
|
+
});
|
|
253
|
+
it("should warn and ignore invalid budget fields", () => {
|
|
254
|
+
const result = validateConfig({
|
|
255
|
+
budget: { daily: "ten", weekly: 0, monthly: -5, warnAt: 2 },
|
|
256
|
+
});
|
|
257
|
+
assert.equal(result.config.budget.daily, undefined);
|
|
258
|
+
assert.equal(result.config.budget.weekly, undefined);
|
|
259
|
+
assert.equal(result.config.budget.monthly, undefined);
|
|
260
|
+
assert.equal(result.config.budget.warnAt, DEFAULT_CONFIG.budget.warnAt);
|
|
261
|
+
assert.ok(result.warnings.length >= 4);
|
|
262
|
+
});
|
|
263
|
+
it("should warn and use default budget for non-object budget", () => {
|
|
264
|
+
const result = validateConfig({ budget: [] });
|
|
265
|
+
assert.deepEqual(result.config.budget, DEFAULT_CONFIG.budget);
|
|
266
|
+
assert.ok(result.warnings.some(w => w.includes("budget")));
|
|
267
|
+
});
|
|
268
|
+
it("should accept zero pricing (free provider)", () => {
|
|
269
|
+
const result = validateConfig({
|
|
270
|
+
providers: { "free": { input: 0, output: 0 } },
|
|
271
|
+
});
|
|
272
|
+
assert.equal(result.warnings.length, 0);
|
|
273
|
+
assert.deepEqual(result.config.providers["free"], { input: 0, output: 0 });
|
|
274
|
+
});
|
|
275
|
+
it("should handle Infinity and NaN gracefully", () => {
|
|
276
|
+
const result = validateConfig({
|
|
277
|
+
budget: { daily: Infinity, warnAt: NaN },
|
|
278
|
+
models: { "m": { input: NaN, output: 1 } },
|
|
279
|
+
});
|
|
280
|
+
assert.equal(result.config.budget.daily, undefined);
|
|
281
|
+
assert.equal(result.config.budget.warnAt, DEFAULT_CONFIG.budget.warnAt);
|
|
282
|
+
assert.equal(result.config.models["m"], undefined);
|
|
283
|
+
assert.ok(result.warnings.length >= 3);
|
|
284
|
+
});
|
|
285
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-token-tracker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
|
+
"test": "tsc && node --test dist/test/shared.test.js",
|
|
13
14
|
"prepublishOnly": "npm run build"
|
|
14
15
|
},
|
|
15
16
|
"keywords": [
|