opencode-token-tracker 1.3.2 → 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.
@@ -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.2",
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": [