opencode-token-tracker 1.5.6 → 1.6.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 +68 -0
- package/dist/bin/opencode-tokens.js +473 -46
- package/dist/index.js +9 -4
- package/dist/lib/shared.d.ts +1 -1
- package/dist/lib/shared.js +13 -7
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +55 -0
- package/dist/test/shared.test.js +79 -0
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -152,8 +152,76 @@ Breakdown options (`--by`):
|
|
|
152
152
|
- `agent` - Group by agent (e.g., sisyphus, coder)
|
|
153
153
|
- `provider` - Group by provider (e.g., anthropic, openai)
|
|
154
154
|
- `daily` - Show day-by-day breakdown
|
|
155
|
+
- `session` - Group by session ID
|
|
155
156
|
- `all` - Show all breakdowns
|
|
156
157
|
|
|
158
|
+
### Trend Chart
|
|
159
|
+
|
|
160
|
+
Visualize your daily token usage and cost over time:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# 30-day cost trend (default)
|
|
164
|
+
opencode-tokens trend
|
|
165
|
+
|
|
166
|
+
# 7-day token count trend
|
|
167
|
+
opencode-tokens trend --days 7 --metric tokens
|
|
168
|
+
|
|
169
|
+
# Compact chart
|
|
170
|
+
opencode-tokens trend --width 40
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Options:
|
|
174
|
+
- `--days N` — Number of days to chart (default 30)
|
|
175
|
+
- `--metric` — `cost` (default), `tokens`, or `messages`
|
|
176
|
+
- `--width W` — Chart width in characters (default 60)
|
|
177
|
+
|
|
178
|
+
### Data Export
|
|
179
|
+
|
|
180
|
+
Export your token usage data for analysis:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Export all data as CSV
|
|
184
|
+
opencode-tokens export
|
|
185
|
+
|
|
186
|
+
# Export this month as JSON
|
|
187
|
+
opencode-tokens export --format json --period month
|
|
188
|
+
|
|
189
|
+
# Export to file
|
|
190
|
+
opencode-tokens export --format csv --output usage.csv
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Options:
|
|
194
|
+
- `--format` — `csv` (default) or `json`
|
|
195
|
+
- `--period` — `today`, `week`, `month`, `all` (default)
|
|
196
|
+
- `--output FILE` — Write to file instead of stdout
|
|
197
|
+
|
|
198
|
+
### Config Management
|
|
199
|
+
|
|
200
|
+
Manage budget and toast settings directly from the CLI:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
# Show current config
|
|
204
|
+
opencode-tokens config
|
|
205
|
+
|
|
206
|
+
# Set daily budget to $10
|
|
207
|
+
opencode-tokens config set budget.daily 10
|
|
208
|
+
|
|
209
|
+
# Disable toast notifications
|
|
210
|
+
opencode-tokens config set toast.enabled false
|
|
211
|
+
|
|
212
|
+
# Check a value
|
|
213
|
+
opencode-tokens config get budget.warnAt
|
|
214
|
+
|
|
215
|
+
# Reset to default
|
|
216
|
+
opencode-tokens config unset budget.daily
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Settable keys:
|
|
220
|
+
- `budget.daily`, `budget.weekly`, `budget.monthly`, `budget.warnAt`
|
|
221
|
+
- `toast.enabled`, `toast.duration`, `toast.showOnIdle`
|
|
222
|
+
|
|
223
|
+
Config changes are automatically backed up to `token-tracker.json.bak`.
|
|
224
|
+
|
|
157
225
|
### Pricing & Config Commands
|
|
158
226
|
|
|
159
227
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "../lib/shared.js";
|
|
3
|
-
import { readFileSync, existsSync, writeFileSync, openSync, readSync, closeSync, statSync } from "fs";
|
|
3
|
+
import { readFileSync, existsSync, writeFileSync, copyFileSync, mkdirSync, openSync, readSync, closeSync, statSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
@@ -15,6 +15,64 @@ function padRight(str, len) {
|
|
|
15
15
|
function padLeft(str, len) {
|
|
16
16
|
return str.length >= len ? str : " ".repeat(len - str.length) + str;
|
|
17
17
|
}
|
|
18
|
+
function truncateSessionId(sessionId) {
|
|
19
|
+
if (!sessionId)
|
|
20
|
+
return "unknown";
|
|
21
|
+
return sessionId.length > 16 ? sessionId.slice(0, 14) + "…" : sessionId;
|
|
22
|
+
}
|
|
23
|
+
function parseArgs(args) {
|
|
24
|
+
const positional = [];
|
|
25
|
+
const flags = new Map();
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < args.length) {
|
|
28
|
+
const arg = args[i];
|
|
29
|
+
if (arg === "--help" || arg === "-h") {
|
|
30
|
+
flags.set("help", true);
|
|
31
|
+
i++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg.startsWith("--")) {
|
|
35
|
+
const eqIndex = arg.indexOf("=");
|
|
36
|
+
if (eqIndex !== -1) {
|
|
37
|
+
flags.set(arg.slice(2, eqIndex), arg.slice(eqIndex + 1));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const next = args[i + 1];
|
|
41
|
+
if (next && !next.startsWith("-")) {
|
|
42
|
+
flags.set(arg.slice(2), next);
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
flags.set(arg.slice(2), true);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg.startsWith("-") && arg.length === 2 && arg !== "--") {
|
|
53
|
+
const next = args[i + 1];
|
|
54
|
+
if (next && !next.startsWith("-")) {
|
|
55
|
+
flags.set(arg.slice(1), next);
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
flags.set(arg.slice(1), true);
|
|
60
|
+
}
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
positional.push(arg);
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
return { command: positional[0] || "", flags, positional };
|
|
68
|
+
}
|
|
69
|
+
function flagValue(flags, name) {
|
|
70
|
+
const v = flags.get(name);
|
|
71
|
+
return typeof v === "string" ? v : undefined;
|
|
72
|
+
}
|
|
73
|
+
function flagBool(flags, name) {
|
|
74
|
+
return flags.has(name);
|
|
75
|
+
}
|
|
18
76
|
// ============================================================================
|
|
19
77
|
// Data Loading
|
|
20
78
|
// ============================================================================
|
|
@@ -246,6 +304,9 @@ function cmdStats(period, breakdown) {
|
|
|
246
304
|
case "daily":
|
|
247
305
|
printDailyBreakdown(entries);
|
|
248
306
|
break;
|
|
307
|
+
case "session":
|
|
308
|
+
printTable("By Session", groupBy(entries, (e) => truncateSessionId(e.sessionId)), "Session");
|
|
309
|
+
break;
|
|
249
310
|
case "all":
|
|
250
311
|
printTable("By Model", groupBy(entries, (e) => e.model ?? "unknown"), "Model");
|
|
251
312
|
printTable("By Agent", groupBy(entries, (e) => e.agent ?? "unknown"), "Agent");
|
|
@@ -325,17 +386,29 @@ function cmdModels() {
|
|
|
325
386
|
console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)}`);
|
|
326
387
|
console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
|
|
327
388
|
for (const { model, provider, count } of sorted) {
|
|
328
|
-
let status
|
|
389
|
+
let status;
|
|
390
|
+
// Mirror the runtime pricing resolution order (getModelPricing in index.ts)
|
|
329
391
|
if (config.providers?.[provider]) {
|
|
330
392
|
status = "provider cfg";
|
|
331
393
|
}
|
|
332
|
-
else if (findModelConfigPricing(config.models, model, provider)) {
|
|
394
|
+
else if (findModelConfigPricing(config.models, model, provider, false)) {
|
|
333
395
|
status = "model cfg";
|
|
334
396
|
}
|
|
335
|
-
else if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
397
|
+
else if (BUILTIN_PRICING[model]) {
|
|
398
|
+
status = "built-in";
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const modelLower = model.toLowerCase();
|
|
402
|
+
const hasBuiltinPartial = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && modelLower.includes(k.toLowerCase()));
|
|
403
|
+
if (hasBuiltinPartial) {
|
|
404
|
+
status = "built-in";
|
|
405
|
+
}
|
|
406
|
+
else if (findModelConfigPricing(config.models, model, provider, true)) {
|
|
407
|
+
status = "model cfg";
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
status = "default";
|
|
411
|
+
}
|
|
339
412
|
}
|
|
340
413
|
console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)}`);
|
|
341
414
|
}
|
|
@@ -347,7 +420,8 @@ function cmdModels() {
|
|
|
347
420
|
console.log(` default = unknown model, using $1/$4 per 1M tokens`);
|
|
348
421
|
console.log();
|
|
349
422
|
}
|
|
350
|
-
function cmdConfig(
|
|
423
|
+
function cmdConfig(positional) {
|
|
424
|
+
const action = positional[1];
|
|
351
425
|
const config = loadConfig();
|
|
352
426
|
const entries = loadEntries();
|
|
353
427
|
if (action === "init" || action === "generate") {
|
|
@@ -454,6 +528,72 @@ function cmdConfig(action) {
|
|
|
454
528
|
}
|
|
455
529
|
return;
|
|
456
530
|
}
|
|
531
|
+
if (action === "get") {
|
|
532
|
+
const key = positional[2];
|
|
533
|
+
if (!key) {
|
|
534
|
+
console.log("\n Usage: opencode-tokens config get <key>\n");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const value = resolveConfigKey(config, key);
|
|
538
|
+
if (value === undefined) {
|
|
539
|
+
console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
console.log(`\n ${key} = ${JSON.stringify(value)}\n`);
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (action === "set") {
|
|
547
|
+
const key = positional[2];
|
|
548
|
+
const rawValue = positional[3];
|
|
549
|
+
if (!key || !rawValue) {
|
|
550
|
+
console.log("\n Usage: opencode-tokens config set <key> <value>\n");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const spec = SETTABLE_KEYS[key];
|
|
554
|
+
if (!spec) {
|
|
555
|
+
console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const value = parseConfigValue(rawValue);
|
|
559
|
+
if (typeof value !== spec.type) {
|
|
560
|
+
console.log(`\n Invalid type: expected ${spec.type}, got ${typeof value}\n`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (typeof value === "number") {
|
|
564
|
+
if (value < 0) {
|
|
565
|
+
console.log(`\n Value must be >= 0\n`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (spec.max !== undefined && value > spec.max) {
|
|
569
|
+
console.log(`\n Value must be <= ${spec.max}\n`);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
applyConfigSet(key, value, config);
|
|
574
|
+
console.log(`\n Set ${key} = ${JSON.stringify(value)}\n`);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (action === "unset") {
|
|
578
|
+
const key = positional[2];
|
|
579
|
+
if (!key) {
|
|
580
|
+
console.log("\n Usage: opencode-tokens config unset <key>\n");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const spec = SETTABLE_KEYS[key];
|
|
584
|
+
if (!spec) {
|
|
585
|
+
console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
applyConfigUnset(key, config);
|
|
589
|
+
console.log(`\n Unset ${key} (reverted to default)\n`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (action && action !== "show") {
|
|
593
|
+
console.log(`\n Unknown config action: ${action}`);
|
|
594
|
+
console.log(` Usage: opencode-tokens config [show|init|generate|get|set|unset]\n`);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
457
597
|
// Show current config
|
|
458
598
|
console.log(`
|
|
459
599
|
Current Configuration
|
|
@@ -469,10 +609,155 @@ function cmdConfig(action) {
|
|
|
469
609
|
console.log();
|
|
470
610
|
}
|
|
471
611
|
console.log(` Commands:`);
|
|
472
|
-
console.log(` opencode-tokens config
|
|
473
|
-
console.log(` opencode-tokens config
|
|
612
|
+
console.log(` opencode-tokens config show Show current config`);
|
|
613
|
+
console.log(` opencode-tokens config init Show example config with explanation`);
|
|
614
|
+
console.log(` opencode-tokens config generate Create config file`);
|
|
615
|
+
console.log(` opencode-tokens config get <key> Get a config value`);
|
|
616
|
+
console.log(` opencode-tokens config set <key> <value> Set a config value`);
|
|
617
|
+
console.log(` opencode-tokens config unset <key> Reset a config value to default`);
|
|
474
618
|
console.log();
|
|
475
619
|
}
|
|
620
|
+
const SETTABLE_KEYS = {
|
|
621
|
+
"budget.daily": { type: "number", path: ["budget", "daily"], default: undefined },
|
|
622
|
+
"budget.weekly": { type: "number", path: ["budget", "weekly"], default: undefined },
|
|
623
|
+
"budget.monthly": { type: "number", path: ["budget", "monthly"], default: undefined },
|
|
624
|
+
"budget.warnAt": { type: "number", path: ["budget", "warnAt"], default: 0.8, max: 1 },
|
|
625
|
+
"toast.enabled": { type: "boolean", path: ["toast", "enabled"], default: true },
|
|
626
|
+
"toast.duration": { type: "number", path: ["toast", "duration"], default: 3000 },
|
|
627
|
+
"toast.showOnIdle": { type: "boolean", path: ["toast", "showOnIdle"], default: true },
|
|
628
|
+
};
|
|
629
|
+
function parseConfigValue(s) {
|
|
630
|
+
if (s === "true")
|
|
631
|
+
return true;
|
|
632
|
+
if (s === "false")
|
|
633
|
+
return false;
|
|
634
|
+
if (s === "null")
|
|
635
|
+
return null;
|
|
636
|
+
if (/^-?\d+(\.\d+)?$/.test(s))
|
|
637
|
+
return Number(s);
|
|
638
|
+
return s;
|
|
639
|
+
}
|
|
640
|
+
function resolveConfigKey(config, key) {
|
|
641
|
+
const spec = SETTABLE_KEYS[key];
|
|
642
|
+
if (!spec)
|
|
643
|
+
return undefined;
|
|
644
|
+
let obj = config;
|
|
645
|
+
for (let i = 0; i < spec.path.length - 1; i++) {
|
|
646
|
+
obj = obj[spec.path[i]];
|
|
647
|
+
if (!obj)
|
|
648
|
+
return spec.default;
|
|
649
|
+
}
|
|
650
|
+
return obj[spec.path[spec.path.length - 1]] ?? spec.default;
|
|
651
|
+
}
|
|
652
|
+
function applyConfigSet(key, value, config) {
|
|
653
|
+
const spec = SETTABLE_KEYS[key];
|
|
654
|
+
const fullConfig = loadOrInitConfig();
|
|
655
|
+
let obj = fullConfig;
|
|
656
|
+
for (let i = 0; i < spec.path.length - 1; i++) {
|
|
657
|
+
if (!obj[spec.path[i]])
|
|
658
|
+
obj[spec.path[i]] = {};
|
|
659
|
+
obj = obj[spec.path[i]];
|
|
660
|
+
}
|
|
661
|
+
obj[spec.path[spec.path.length - 1]] = value;
|
|
662
|
+
saveConfig(fullConfig);
|
|
663
|
+
}
|
|
664
|
+
function applyConfigUnset(key, config) {
|
|
665
|
+
const spec = SETTABLE_KEYS[key];
|
|
666
|
+
const fullConfig = loadOrInitConfig();
|
|
667
|
+
let obj = fullConfig;
|
|
668
|
+
for (let i = 0; i < spec.path.length - 1; i++) {
|
|
669
|
+
if (!obj[spec.path[i]])
|
|
670
|
+
return;
|
|
671
|
+
obj = obj[spec.path[i]];
|
|
672
|
+
}
|
|
673
|
+
delete obj[spec.path[spec.path.length - 1]];
|
|
674
|
+
saveConfig(fullConfig);
|
|
675
|
+
}
|
|
676
|
+
function loadOrInitConfig() {
|
|
677
|
+
if (existsSync(CONFIG_FILE)) {
|
|
678
|
+
try {
|
|
679
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
680
|
+
}
|
|
681
|
+
catch { }
|
|
682
|
+
}
|
|
683
|
+
return {};
|
|
684
|
+
}
|
|
685
|
+
function saveConfig(raw) {
|
|
686
|
+
const dir = join(homedir(), ".config", "opencode");
|
|
687
|
+
if (!existsSync(dir)) {
|
|
688
|
+
mkdirSync(dir, { recursive: true });
|
|
689
|
+
}
|
|
690
|
+
if (existsSync(CONFIG_FILE)) {
|
|
691
|
+
copyFileSync(CONFIG_FILE, CONFIG_FILE + ".bak");
|
|
692
|
+
}
|
|
693
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, 2) + "\n");
|
|
694
|
+
}
|
|
695
|
+
// ============================================================================
|
|
696
|
+
// Export
|
|
697
|
+
// ============================================================================
|
|
698
|
+
function cmdExport(flags) {
|
|
699
|
+
const format = flagValue(flags, "format") || "csv";
|
|
700
|
+
const period = flagValue(flags, "period") || "all";
|
|
701
|
+
const outputFile = flagValue(flags, "output");
|
|
702
|
+
const now = new Date();
|
|
703
|
+
let since;
|
|
704
|
+
switch (period) {
|
|
705
|
+
case "today":
|
|
706
|
+
since = getStartOfDay(now);
|
|
707
|
+
break;
|
|
708
|
+
case "week":
|
|
709
|
+
since = getStartOfWeek(now);
|
|
710
|
+
break;
|
|
711
|
+
case "month":
|
|
712
|
+
since = getStartOfMonth(now);
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
const entries = loadEntries(since);
|
|
716
|
+
if (entries.length === 0) {
|
|
717
|
+
console.log(`\n No data to export for ${period}\n`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
let output;
|
|
721
|
+
if (format === "json") {
|
|
722
|
+
output = JSON.stringify(entries, null, 2);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
const headers = ["timestamp", "date", "session_id", "message_id", "role", "agent", "model", "provider", "input", "output", "reasoning", "cache_read", "cache_write", "cost"];
|
|
726
|
+
const rows = entries.map(e => [
|
|
727
|
+
e._ts,
|
|
728
|
+
new Date(e._ts).toISOString().slice(0, 10),
|
|
729
|
+
e.sessionId ?? "",
|
|
730
|
+
e.messageId ?? "",
|
|
731
|
+
e.role ?? "",
|
|
732
|
+
e.agent ?? "",
|
|
733
|
+
e.model ?? "",
|
|
734
|
+
e.provider ?? "",
|
|
735
|
+
e.input ?? 0,
|
|
736
|
+
e.output ?? 0,
|
|
737
|
+
e.reasoning ?? 0,
|
|
738
|
+
e.cacheRead ?? 0,
|
|
739
|
+
e.cacheWrite ?? 0,
|
|
740
|
+
e.cost ?? 0,
|
|
741
|
+
].map(csvEscape).join(","));
|
|
742
|
+
output = [headers.join(","), ...rows].join("\n") + "\n";
|
|
743
|
+
}
|
|
744
|
+
if (outputFile) {
|
|
745
|
+
writeFileSync(outputFile, output);
|
|
746
|
+
console.log(`\n Exported ${entries.length} entries to ${outputFile}\n`);
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
process.stdout.write(output);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function csvEscape(v) {
|
|
753
|
+
if (v == null)
|
|
754
|
+
return "";
|
|
755
|
+
const s = String(v);
|
|
756
|
+
if (/[",\n\r]/.test(s)) {
|
|
757
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
758
|
+
}
|
|
759
|
+
return s;
|
|
760
|
+
}
|
|
476
761
|
function cmdBudget() {
|
|
477
762
|
const config = loadConfig();
|
|
478
763
|
const budget = config.budget;
|
|
@@ -572,64 +857,206 @@ function cmdHelp() {
|
|
|
572
857
|
(default) Show usage statistics
|
|
573
858
|
budget Show budget status (daily/weekly/monthly)
|
|
574
859
|
pricing Show built-in pricing table
|
|
575
|
-
models Show your used models and their pricing status
|
|
576
|
-
config Show/generate configuration
|
|
860
|
+
models Show your used models and their pricing status
|
|
861
|
+
config Show/generate/modify configuration
|
|
862
|
+
export Export token data to CSV/JSON
|
|
863
|
+
trend Show daily cost/tokens trend chart
|
|
577
864
|
|
|
578
865
|
Statistics Options:
|
|
579
866
|
today Show today's usage
|
|
580
|
-
week Show this week's usage
|
|
867
|
+
week Show this week's usage
|
|
581
868
|
month Show this month's usage
|
|
582
869
|
all Show all-time usage (default)
|
|
583
|
-
|
|
584
|
-
--by <type> Group by: model, agent, provider, daily, all
|
|
870
|
+
|
|
871
|
+
--by <type> Group by: model, agent, provider, session, daily, all
|
|
872
|
+
|
|
873
|
+
Export Options:
|
|
874
|
+
--format csv (default) or json
|
|
875
|
+
--period today, week, month, all (default)
|
|
876
|
+
--output Write to file instead of stdout
|
|
877
|
+
|
|
878
|
+
Trend Options:
|
|
879
|
+
--days N Number of days to chart (default 30)
|
|
880
|
+
--metric cost (default), tokens, or messages
|
|
881
|
+
--width W Chart width in characters (default 60)
|
|
882
|
+
|
|
883
|
+
Config Sub-commands:
|
|
884
|
+
config show Show current config
|
|
885
|
+
config init Show example config with explanation
|
|
886
|
+
config generate Create config file
|
|
887
|
+
config get <key> Get a config value
|
|
888
|
+
config set <key> <value> Set a config value
|
|
889
|
+
config unset <key> Reset a config value to default
|
|
890
|
+
|
|
891
|
+
Settable config keys:
|
|
892
|
+
budget.daily, budget.weekly, budget.monthly, budget.warnAt
|
|
893
|
+
toast.enabled, toast.duration, toast.showOnIdle
|
|
585
894
|
|
|
586
895
|
Examples:
|
|
587
|
-
opencode-tokens
|
|
588
|
-
opencode-tokens
|
|
589
|
-
opencode-tokens
|
|
590
|
-
opencode-tokens
|
|
591
|
-
opencode-tokens
|
|
592
|
-
opencode-tokens
|
|
593
|
-
opencode-tokens config
|
|
896
|
+
opencode-tokens # All-time summary
|
|
897
|
+
opencode-tokens today --by model # Today by model
|
|
898
|
+
opencode-tokens week --by session # This week by session
|
|
899
|
+
opencode-tokens trend --days 7 # 7-day cost trend
|
|
900
|
+
opencode-tokens export --format csv # Export all data as CSV
|
|
901
|
+
opencode-tokens config set budget.daily 10 # Set daily budget to $10
|
|
902
|
+
opencode-tokens config get toast.enabled # Check if toast is enabled
|
|
594
903
|
`);
|
|
595
904
|
}
|
|
596
905
|
// ============================================================================
|
|
597
|
-
//
|
|
906
|
+
// Trend
|
|
598
907
|
// ============================================================================
|
|
599
|
-
function
|
|
600
|
-
const
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
908
|
+
function cmdTrend(flags) {
|
|
909
|
+
const days = parseInt(String(flagValue(flags, "days") ?? "30"), 10);
|
|
910
|
+
const metric = flagValue(flags, "metric") ?? "cost";
|
|
911
|
+
const width = parseInt(String(flagValue(flags, "width") ?? "60"), 10);
|
|
912
|
+
const since = getStartOfDay(new Date(Date.now() - days * 86400000));
|
|
913
|
+
const entries = loadEntries(since);
|
|
914
|
+
if (entries.length === 0) {
|
|
915
|
+
console.log(`\n (no data in period)\n`);
|
|
604
916
|
return;
|
|
605
917
|
}
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
918
|
+
// Aggregate by day
|
|
919
|
+
const dayMap = new Map();
|
|
920
|
+
for (const e of entries) {
|
|
921
|
+
const dayStart = getStartOfDay(new Date(e._ts));
|
|
922
|
+
if (!dayMap.has(dayStart)) {
|
|
923
|
+
dayMap.set(dayStart, { cost: 0, tokens: 0, messages: 0 });
|
|
924
|
+
}
|
|
925
|
+
const d = dayMap.get(dayStart);
|
|
926
|
+
d.cost += e.cost ?? 0;
|
|
927
|
+
d.tokens += (e.input ?? 0) + (e.output ?? 0) + (e.reasoning ?? 0);
|
|
928
|
+
d.messages += 1;
|
|
610
929
|
}
|
|
611
|
-
|
|
612
|
-
|
|
930
|
+
const sorted = Array.from(dayMap.entries()).sort(([a], [b]) => a - b);
|
|
931
|
+
if (sorted.length < 2) {
|
|
932
|
+
const only = sorted[0];
|
|
933
|
+
if (only) {
|
|
934
|
+
const v = metric === "tokens" ? formatTokens(only[1].tokens) : metric === "messages" ? String(only[1].messages) : formatCost(only[1].cost);
|
|
935
|
+
console.log(`\n ${new Date(only[0]).toISOString().slice(0, 10)}: ${v}\n`);
|
|
936
|
+
}
|
|
613
937
|
return;
|
|
614
938
|
}
|
|
615
|
-
|
|
616
|
-
|
|
939
|
+
const values = sorted.map(([, d]) => metric === "tokens" ? d.tokens : metric === "messages" ? d.messages : d.cost);
|
|
940
|
+
const maxVal = Math.max(...values, 1);
|
|
941
|
+
const H = Math.max(5, Math.min(Math.floor(width / 3), 20));
|
|
942
|
+
if (width < 35) {
|
|
943
|
+
// Fallback: simple sparkline
|
|
944
|
+
const chars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
945
|
+
const spark = values.map(v => chars[Math.min(Math.floor((v / maxVal) * 7), 7)]).join("");
|
|
946
|
+
console.log(`\n ${spark}\n`);
|
|
617
947
|
return;
|
|
618
948
|
}
|
|
619
|
-
|
|
620
|
-
|
|
949
|
+
// Build chart
|
|
950
|
+
const cols = values.map((v) => ({ value: v, y: Math.round((v / maxVal) * (H - 1)) }));
|
|
951
|
+
const chartWidth = Math.max(width - 12, 20);
|
|
952
|
+
const xStep = Math.max(2, Math.floor(chartWidth / sorted.length));
|
|
953
|
+
const yLabelStep = Math.max(1, Math.floor(H / 5));
|
|
954
|
+
const lines = [];
|
|
955
|
+
for (let row = H - 1; row >= 0; row--) {
|
|
956
|
+
let line = "";
|
|
957
|
+
const valAtRow = (row / (H - 1)) * maxVal;
|
|
958
|
+
const label = row === H - 1 || row === 0 || (H - 1 - row) % yLabelStep === 0
|
|
959
|
+
? metric === "tokens" ? formatTokens(valAtRow) : metric === "messages" ? String(Math.round(valAtRow)) : formatCost(valAtRow)
|
|
960
|
+
: "";
|
|
961
|
+
line += padLeft(label, 9);
|
|
962
|
+
line += row === 0 ? " ┼" : " ┤";
|
|
963
|
+
for (let ci = 0; ci < cols.length; ci++) {
|
|
964
|
+
const col = cols[ci];
|
|
965
|
+
const nextY = ci < cols.length - 1 ? cols[ci + 1].y : col.y;
|
|
966
|
+
if (col.y === row) {
|
|
967
|
+
if (ci > 0) {
|
|
968
|
+
const prevY = cols[ci - 1].y;
|
|
969
|
+
if (prevY < col.y && nextY <= col.y)
|
|
970
|
+
line += "╭";
|
|
971
|
+
else if (prevY > col.y && nextY >= col.y)
|
|
972
|
+
line += "╰";
|
|
973
|
+
else if (prevY < col.y || nextY < col.y)
|
|
974
|
+
line += "╭";
|
|
975
|
+
else if (prevY > col.y || nextY > col.y)
|
|
976
|
+
line += "╰";
|
|
977
|
+
else
|
|
978
|
+
line += "─";
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
line += nextY > col.y ? "╭" : nextY < col.y ? "╰" : "─";
|
|
982
|
+
}
|
|
983
|
+
if (xStep > 1 && ci < cols.length - 1 && nextY === row) {
|
|
984
|
+
line += "─".repeat(xStep - 1);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
else if (ci > 0 && ci < cols.length) {
|
|
988
|
+
const prevY = cols[ci - 1].y;
|
|
989
|
+
if ((prevY < row && col.y > row) || (prevY > row && col.y < row)) {
|
|
990
|
+
line += prevY < col.y ? "╱" : "╲";
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
line += " ".repeat(xStep > 1 && nextY !== row ? 1 : Math.min(xStep, 1));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
lines.push(line);
|
|
998
|
+
}
|
|
999
|
+
// Bottom axis
|
|
1000
|
+
let axis = " ".repeat(9) + " └";
|
|
1001
|
+
axis += "─".repeat(xStep * cols.length);
|
|
1002
|
+
lines.push(axis);
|
|
1003
|
+
// X axis labels
|
|
1004
|
+
const labelStep = Math.max(1, Math.ceil(sorted.length / 6));
|
|
1005
|
+
let xLabels = " ".repeat(11);
|
|
1006
|
+
for (let i = 0; i < cols.length; i++) {
|
|
1007
|
+
if (i % labelStep === 0 || i === cols.length - 1) {
|
|
1008
|
+
const d = new Date(sorted[i][0]);
|
|
1009
|
+
const ds = `${d.getMonth() + 1}/${d.getDate()}`;
|
|
1010
|
+
xLabels += ds;
|
|
1011
|
+
if (i < cols.length - 1)
|
|
1012
|
+
xLabels += " ".repeat(Math.max(1, xStep - ds.length + 1));
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
lines.push(xLabels);
|
|
1016
|
+
console.log();
|
|
1017
|
+
for (const l of lines)
|
|
1018
|
+
console.log(" " + l);
|
|
1019
|
+
console.log();
|
|
1020
|
+
}
|
|
1021
|
+
// ============================================================================
|
|
1022
|
+
// Main
|
|
1023
|
+
// ============================================================================
|
|
1024
|
+
function main() {
|
|
1025
|
+
const args = process.argv.slice(2);
|
|
1026
|
+
const parsed = parseArgs(args);
|
|
1027
|
+
if (parsed.flags.has("help")) {
|
|
1028
|
+
cmdHelp();
|
|
621
1029
|
return;
|
|
622
1030
|
}
|
|
623
|
-
|
|
1031
|
+
const { command } = parsed;
|
|
1032
|
+
switch (command) {
|
|
1033
|
+
case "budget":
|
|
1034
|
+
cmdBudget();
|
|
1035
|
+
return;
|
|
1036
|
+
case "pricing":
|
|
1037
|
+
cmdPricing();
|
|
1038
|
+
return;
|
|
1039
|
+
case "models":
|
|
1040
|
+
cmdModels();
|
|
1041
|
+
return;
|
|
1042
|
+
case "config":
|
|
1043
|
+
cmdConfig(parsed.positional);
|
|
1044
|
+
return;
|
|
1045
|
+
case "export":
|
|
1046
|
+
cmdExport(parsed.flags);
|
|
1047
|
+
return;
|
|
1048
|
+
case "trend":
|
|
1049
|
+
cmdTrend(parsed.flags);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
// Default: stats
|
|
624
1053
|
let period = "all";
|
|
625
1054
|
let breakdown;
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
else if (["today", "week", "month", "all"].includes(arg)) {
|
|
632
|
-
period = arg;
|
|
1055
|
+
breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
|
|
1056
|
+
for (const p of ["today", "week", "month", "all"]) {
|
|
1057
|
+
if (parsed.positional.includes(p)) {
|
|
1058
|
+
period = p;
|
|
1059
|
+
break;
|
|
633
1060
|
}
|
|
634
1061
|
}
|
|
635
1062
|
cmdStats(period, breakdown);
|
package/dist/index.js
CHANGED
|
@@ -58,8 +58,8 @@ function getModelPricing(model, provider) {
|
|
|
58
58
|
if (config.providers[provider]) {
|
|
59
59
|
return config.providers[provider];
|
|
60
60
|
}
|
|
61
|
-
// 2. Check user-defined model pricing
|
|
62
|
-
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);
|
|
63
63
|
if (configuredPricing) {
|
|
64
64
|
return configuredPricing;
|
|
65
65
|
}
|
|
@@ -69,12 +69,17 @@ function getModelPricing(model, provider) {
|
|
|
69
69
|
}
|
|
70
70
|
// 4. Try partial match in built-in pricing
|
|
71
71
|
const modelLower = model.toLowerCase();
|
|
72
|
-
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)) {
|
|
73
73
|
if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
|
|
74
74
|
return pricing;
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
// 5.
|
|
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
|
|
78
83
|
return BUILTIN_PRICING["_default"];
|
|
79
84
|
}
|
|
80
85
|
export function getProviderFamily(model, provider) {
|
package/dist/lib/shared.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/shared.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { join, dirname } from "path";
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const CLI = join(__dirname, "..", "bin", "opencode-tokens.js");
|
|
8
|
+
function run(args) {
|
|
9
|
+
return execSync(`node ${CLI} ${args}`, { encoding: "utf-8", timeout: 10000 });
|
|
10
|
+
}
|
|
11
|
+
describe("CLI help and stats", () => {
|
|
12
|
+
it("should show help", () => {
|
|
13
|
+
const out = run("--help");
|
|
14
|
+
assert.ok(out.includes("opencode-tokens - Token usage statistics CLI"));
|
|
15
|
+
assert.ok(out.includes("trend"));
|
|
16
|
+
assert.ok(out.includes("export"));
|
|
17
|
+
assert.ok(out.includes("config set"));
|
|
18
|
+
});
|
|
19
|
+
it("should show stats by session", () => {
|
|
20
|
+
const out = run("--by session");
|
|
21
|
+
assert.ok(out.includes("By Session") || out.includes("No data"));
|
|
22
|
+
});
|
|
23
|
+
it("should show budget", () => {
|
|
24
|
+
const out = run("budget");
|
|
25
|
+
assert.ok(out.includes("Budget Status"));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("CLI config", () => {
|
|
29
|
+
it("should set, get, and unset config value", () => {
|
|
30
|
+
run("config set toast.duration 5000");
|
|
31
|
+
const out = run("config get toast.duration");
|
|
32
|
+
assert.ok(out.includes("5000"));
|
|
33
|
+
run("config unset toast.duration");
|
|
34
|
+
});
|
|
35
|
+
it("should reject unknown config key", () => {
|
|
36
|
+
const out = run("config get nonexistent.key");
|
|
37
|
+
assert.ok(out.includes("Unknown key"));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe("CLI export", () => {
|
|
41
|
+
it("should export as CSV", () => {
|
|
42
|
+
const out = run("export --format csv --period today");
|
|
43
|
+
assert.ok(out.includes("timestamp") || out.includes("No data"));
|
|
44
|
+
});
|
|
45
|
+
it("should export as JSON", () => {
|
|
46
|
+
const out = run("export --format json --period today");
|
|
47
|
+
assert.ok(out.startsWith("[") || out.includes("No data"));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe("CLI trend", () => {
|
|
51
|
+
it("should show trend chart", () => {
|
|
52
|
+
const out = run("trend --days 7");
|
|
53
|
+
assert.ok(out.includes("┤") || out.includes("(no data"));
|
|
54
|
+
});
|
|
55
|
+
});
|
package/dist/test/shared.test.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.6.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",
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"opencode-tokens": "dist/bin/opencode-tokens.js"
|
|
10
10
|
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "tsc && node --test dist/test/shared.test.js dist/test/cli.test.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
11
16
|
"keywords": [
|
|
12
17
|
"opencode",
|
|
13
18
|
"plugin",
|
|
@@ -46,9 +51,5 @@
|
|
|
46
51
|
],
|
|
47
52
|
"engines": {
|
|
48
53
|
"node": ">=18.0.0"
|
|
49
|
-
},
|
|
50
|
-
"scripts": {
|
|
51
|
-
"build": "tsc",
|
|
52
|
-
"test": "tsc && node --test dist/test/shared.test.js"
|
|
53
54
|
}
|
|
54
|
-
}
|
|
55
|
+
}
|