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 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 = "built-in";
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 (!BUILTIN_PRICING[model]) {
336
- // Check partial match
337
- const hasMatch = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && model.toLowerCase().includes(k.toLowerCase()));
338
- status = hasMatch ? "built-in" : "default";
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(action) {
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 init Show example config with explanation`);
473
- console.log(` opencode-tokens config generate Create config file`);
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 # All-time summary
588
- opencode-tokens budget # Check budget status
589
- opencode-tokens today # Today's summary
590
- opencode-tokens week --by model # This week, by model
591
- opencode-tokens pricing # Show pricing table
592
- opencode-tokens models # Show your models
593
- opencode-tokens config init # Generate example 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
- // Main
906
+ // Trend
598
907
  // ============================================================================
599
- function main() {
600
- const args = process.argv.slice(2);
601
- const command = args[0];
602
- if (args.includes("--help") || args.includes("-h")) {
603
- cmdHelp();
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
- // Handle subcommands
607
- if (command === "budget") {
608
- cmdBudget();
609
- return;
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
- if (command === "pricing") {
612
- cmdPricing();
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
- if (command === "models") {
616
- cmdModels();
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
- if (command === "config") {
620
- cmdConfig(args[1]);
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
- // Parse stats arguments
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
- for (let i = 0; i < args.length; i++) {
627
- const arg = args[i];
628
- if (arg === "--by" || arg === "-b") {
629
- breakdown = args[++i];
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. Fallback to default
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) {
@@ -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;
@@ -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
- for (const [key, entry] of Object.entries(models)) {
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
+ });
@@ -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.5.6",
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
+ }