opencode-token-tracker 1.6.4 → 1.7.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.
@@ -2,7 +2,7 @@
2
2
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, validateConfig } from "../lib/shared.js";
5
+ import { BUILTIN_PRICING, DEFAULT_CONFIG, formatCost, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, validateConfig, BUILTIN_PRICING_META, resolvePricingStatus, round2 } from "../lib/shared.js";
6
6
  const CONFIG_DIR = join(homedir(), ".config", "opencode");
7
7
  const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
8
8
  const LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "tokens.jsonl");
@@ -314,8 +314,11 @@ function cmdStats(period, breakdown) {
314
314
  function cmdPricing() {
315
315
  const config = loadConfig();
316
316
  console.log(`
317
- Built-in Pricing Table (USD per 1M tokens) - Updated 2026-02-11
317
+ Built-in Pricing Table (USD per 1M tokens)
318
318
  ══════════════════════════════════════════════════════════════════
319
+ Pricing last updated: ${BUILTIN_PRICING_META.pricingLastUpdated}
320
+ Metadata last updated: ${BUILTIN_PRICING_META.metadataLastUpdated}
321
+ Source: ${BUILTIN_PRICING_META.source}
319
322
  `);
320
323
  // Group by provider
321
324
  const groups = {
@@ -347,6 +350,12 @@ function cmdPricing() {
347
350
  if (Object.keys(config.models || {}).length > 0) {
348
351
  console.log(` * = overridden in config`);
349
352
  }
353
+ console.log(` Fallback Pricing Notice:`);
354
+ console.log(` When a model is not matched in the built-in pricing table or user configuration,`);
355
+ console.log(` it falls back to the default rate ($1.0 / $4.0 per 1M tokens).`);
356
+ console.log(` You can easily override it in your configuration.`);
357
+ console.log(` ${BUILTIN_PRICING_META.notes}`);
358
+ console.log();
350
359
  }
351
360
  function cmdModels() {
352
361
  const entries = loadEntries();
@@ -384,30 +393,7 @@ function cmdModels() {
384
393
  console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)}`);
385
394
  console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
386
395
  for (const { model, provider, count } of sorted) {
387
- let status;
388
- // Mirror the runtime pricing resolution order (getModelPricing in index.ts)
389
- if (config.providers?.[provider]) {
390
- status = "provider cfg";
391
- }
392
- else if (findModelConfigPricing(config.models, model, provider, false)) {
393
- status = "model cfg";
394
- }
395
- else if (BUILTIN_PRICING[model]) {
396
- status = "built-in";
397
- }
398
- else {
399
- const modelLower = model.toLowerCase();
400
- const hasBuiltinPartial = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && modelLower.includes(k.toLowerCase()));
401
- if (hasBuiltinPartial) {
402
- status = "built-in";
403
- }
404
- else if (findModelConfigPricing(config.models, model, provider, true)) {
405
- status = "model cfg";
406
- }
407
- else {
408
- status = "default";
409
- }
410
- }
396
+ const status = resolvePricingStatus(config, model, provider);
411
397
  console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)}`);
412
398
  }
413
399
  console.log();
@@ -423,22 +409,25 @@ function cmdConfig(positional) {
423
409
  const config = loadConfig();
424
410
  const entries = loadEntries();
425
411
  if (action === "init" || action === "generate") {
426
- // Get unique providers from logs
412
+ // Get unique providers and model+provider combinations from logs
427
413
  const providers = new Set();
428
- const models = new Set();
414
+ const modelProviders = new Set();
429
415
  for (const e of entries) {
430
416
  if (e.provider)
431
417
  providers.add(e.provider);
432
- if (e.model)
433
- models.add(e.model);
418
+ const model = e.model ?? "unknown";
419
+ const provider = e.provider ?? "unknown";
420
+ modelProviders.add(`${model}|${provider}`);
434
421
  }
435
- // Find providers/models without built-in pricing
436
- const unknownModels = Array.from(models).filter(m => {
437
- if (BUILTIN_PRICING[m])
438
- return false;
439
- const hasMatch = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && m.toLowerCase().includes(k.toLowerCase()));
440
- return !hasMatch;
441
- });
422
+ // Estimate daily avg from last 7 days
423
+ const now = Date.now();
424
+ const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
425
+ const recentEntries = entries.filter(e => e._ts >= sevenDaysAgo);
426
+ const totalSpent = recentEntries.reduce((sum, e) => sum + (e.cost ?? 0), 0);
427
+ const dailyAvg = totalSpent / 7;
428
+ const dailyLimit = dailyAvg > 0 ? Math.max(0.5, round2(dailyAvg * 1.5)) : 5;
429
+ const weeklyLimit = dailyAvg > 0 ? Math.max(0.5, round2(dailyAvg * 7 * 1.3)) : 25;
430
+ const monthlyLimit = dailyAvg > 0 ? Math.max(0.5, round2(dailyAvg * 30 * 1.2)) : 100;
442
431
  const exampleConfig = {
443
432
  providers: {},
444
433
  models: {},
@@ -448,25 +437,74 @@ function cmdConfig(positional) {
448
437
  showOnIdle: true,
449
438
  },
450
439
  budget: {
451
- daily: 5,
452
- weekly: 25,
453
- monthly: 100,
440
+ daily: dailyLimit,
441
+ weekly: weeklyLimit,
442
+ monthly: monthlyLimit,
454
443
  warnAt: 0.8,
455
444
  },
456
445
  };
457
- // Add providers as comments/examples
446
+ const suggestedProviders = [];
447
+ // Add providers as comments/examples (Common free providers)
458
448
  for (const provider of providers) {
459
- // Common free providers
460
- if (provider.includes("copilot") || provider.includes("cursor") || provider.includes("free")) {
449
+ const pLower = provider.toLowerCase();
450
+ if (pLower.includes("copilot") || pLower.includes("cursor") || pLower.includes("free")) {
461
451
  exampleConfig.providers[provider] = { input: 0, output: 0 };
452
+ suggestedProviders.push(provider);
453
+ }
454
+ }
455
+ const suggestedModels = [];
456
+ // Add unknown/fallback models where status is "default"
457
+ for (const mp of modelProviders) {
458
+ const [model, provider] = mp.split("|");
459
+ if (model === "unknown" || provider === "unknown")
460
+ continue;
461
+ const status = resolvePricingStatus(config, model, provider);
462
+ if (status === "default") {
463
+ exampleConfig.models[model] = { input: 1, output: 4 };
464
+ suggestedModels.push(`${model} (${provider})`);
462
465
  }
463
466
  }
464
- // Add unknown models
465
- for (const model of unknownModels) {
466
- exampleConfig.models[model] = { input: 1, output: 4 };
467
+ // Construct Dynamic Usage-Aware Suggestions Summary
468
+ let suggestionsSummary = `
469
+ 📢 Usage-Aware Suggestions Summary
470
+ ══════════════════════════════════════════════════════════════════
471
+ - Analyzed timeframe: Last 7 days
472
+ - Found ${entries.length} historical usage entries in total.
473
+ - Calculated 7-day average daily cost: $${round2(dailyAvg).toFixed(4)}
474
+ `;
475
+ if (dailyAvg > 0) {
476
+ suggestionsSummary += `
477
+ Recommended Budget Limits (derived from daily avg $${round2(dailyAvg).toFixed(4)}):
478
+ - Daily Limit : $${dailyLimit.toFixed(2)} (Avg * 1.5 buffer, clamped min $0.50)
479
+ - Weekly Limit : $${weeklyLimit.toFixed(2)} (Avg * 7 * 1.3 buffer, clamped min $0.50)
480
+ - Monthly Limit : $${monthlyLimit.toFixed(2)} (Avg * 30 * 1.2 buffer, clamped min $0.50)
481
+ `;
467
482
  }
468
- // Print explanation first
469
- console.log(`
483
+ else {
484
+ suggestionsSummary += `
485
+ No active usage logs detected in the last 7 days.
486
+ - Applying fallback default budgets: Daily $5.00, Weekly $25.00, Monthly $100.00.
487
+ `;
488
+ }
489
+ if (suggestedProviders.length > 0) {
490
+ suggestionsSummary += `
491
+ Detected zero-cost/subscription providers:
492
+ `;
493
+ for (const p of suggestedProviders) {
494
+ suggestionsSummary += ` • ${p} (automatically pre-configured to $0.00)\n`;
495
+ }
496
+ }
497
+ if (suggestedModels.length > 0) {
498
+ suggestionsSummary += `
499
+ Detected unrecognized fallback models:
500
+ `;
501
+ for (const m of suggestedModels) {
502
+ suggestionsSummary += ` • ${m} (automatically pre-configured with fallback $1/$4 rate)\n`;
503
+ }
504
+ }
505
+ suggestionsSummary += ` ────────────────────────────────────────────────────────────────\n`;
506
+ // Print explanation first to stderr
507
+ const guideText = `
470
508
  Configuration Guide
471
509
  ══════════════════════════════════════════════════════════════════
472
510
 
@@ -507,22 +545,25 @@ function cmdConfig(positional) {
507
545
 
508
546
  ────────────────────────────────────────────────────────────────
509
547
  Example config based on your usage:
510
- `);
511
- console.log(JSON.stringify(exampleConfig, null, 2));
512
- if (action === "generate") {
513
- const json = JSON.stringify(exampleConfig, null, 2);
514
- writeFileSync(CONFIG_FILE, json);
515
- console.log(`
516
- Config file created: ${CONFIG_FILE}
517
- `);
518
- }
519
- else {
520
- console.log(`
548
+ `;
549
+ process.stderr.write(suggestionsSummary + "\n");
550
+ process.stderr.write(guideText + "\n");
551
+ if (action === "init") {
552
+ // Print clean config to stdout ONLY on init
553
+ process.stdout.write(JSON.stringify(exampleConfig, null, 2) + "\n");
554
+ process.stderr.write(`
521
555
  To create this config file, run:
522
556
  opencode-tokens config generate
523
557
 
524
558
  Or manually create: ${CONFIG_FILE}
525
- `);
559
+ \n`);
560
+ }
561
+ else if (action === "generate") {
562
+ // Generate has completely empty stdout! Writes to config file, alerts to stderr
563
+ saveConfig(exampleConfig);
564
+ process.stderr.write(`
565
+ Config file created: ${CONFIG_FILE}
566
+ \n`);
526
567
  }
527
568
  return;
528
569
  }
@@ -777,6 +818,8 @@ function cmdBudget() {
777
818
  }
778
819
 
779
820
  Run: opencode-tokens config init for more details.
821
+
822
+ Costs are estimates from local logs; budgets are warnings, not enforcement.
780
823
  `);
781
824
  return;
782
825
  }
@@ -841,6 +884,7 @@ function cmdBudget() {
841
884
  console.log();
842
885
  }
843
886
  console.log(` Legend: 🟢 OK 🟡 Warning (>${Math.round(warnAt * 100)}%) 🔴 Exceeded`);
887
+ console.log(` Costs are estimates from local logs; budgets are warnings, not enforcement.`);
844
888
  console.log();
845
889
  }
846
890
  function cmdHelp() {
@@ -897,9 +941,10 @@ function cmdHelp() {
897
941
  opencode-tokens export --format csv # Export all data as CSV
898
942
  opencode-tokens config set budget.daily 10 # Set daily budget to $10
899
943
  opencode-tokens config get toast.enabled # Check if toast is enabled
944
+
945
+ Costs are estimates from local logs; budgets are warnings, not enforcement.
900
946
  `);
901
947
  }
902
- const BRAILLE_DOTS = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80];
903
948
  function getTrendValue(point, metric) {
904
949
  return metric === "tokens" ? point.tokens : metric === "messages" ? point.messages : point.cost;
905
950
  }
@@ -913,43 +958,110 @@ function formatSignedTrendValue(value, metric) {
913
958
  function metricLabel(metric) {
914
959
  return metric === "tokens" ? "Token Trend" : metric === "messages" ? "Message Trend" : "Cost Trend";
915
960
  }
916
- function addBraillePoint(cells, x, y) {
917
- const cellX = Math.floor(x / 2);
918
- const cellY = Math.floor(y / 4);
919
- const dotX = x % 2;
920
- const dotY = y % 4;
921
- cells[cellY][cellX] |= BRAILLE_DOTS[dotX * 4 + dotY];
922
- }
923
- function buildBrailleRows(points, width, height) {
924
- const cells = Array.from({ length: height }, () => Array.from({ length: width }, () => 0));
925
- for (let i = 1; i < points.length; i++) {
926
- const from = points[i - 1];
927
- const to = points[i];
928
- const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y), 1);
929
- for (let step = 0; step <= steps; step++) {
930
- const t = step / steps;
931
- addBraillePoint(cells, Math.round(from.x + (to.x - from.x) * t), Math.round(from.y + (to.y - from.y) * t));
961
+ function buildLineRows(points, width, height) {
962
+ const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => " "));
963
+ // 1. Calculate the exact integer row r[x] for every column x from 0 to width - 1
964
+ const r = Array.from({ length: width }, () => 0);
965
+ for (let x = 0; x < width; x++) {
966
+ // Find which segment p[i] -> p[i+1] contains x
967
+ let i = 0;
968
+ for (; i < points.length - 1; i++) {
969
+ if (x >= points[i].x && x <= points[i + 1].x) {
970
+ break;
971
+ }
972
+ }
973
+ // Clamp to valid index
974
+ if (i >= points.length - 1) {
975
+ i = points.length - 2;
976
+ }
977
+ const p1 = points[i];
978
+ const p2 = points[i + 1];
979
+ if (p1.x === p2.x) {
980
+ r[x] = p1.y;
981
+ }
982
+ else {
983
+ const t = (x - p1.x) / (p2.x - p1.x);
984
+ const y = p1.y + (p2.y - p1.y) * t;
985
+ r[x] = Math.round(y);
986
+ }
987
+ }
988
+ // 2. Plot the line characters based on r[x] and r[x+1]
989
+ for (let x = 0; x < width; x++) {
990
+ const rCurr = r[x];
991
+ if (x === width - 1) {
992
+ // Last column has no next column, draw horizontal line
993
+ if (grid[rCurr][x] === " ") {
994
+ grid[rCurr][x] = "─";
995
+ }
996
+ break;
997
+ }
998
+ const rNext = r[x + 1];
999
+ if (rCurr === rNext) {
1000
+ if (grid[rCurr][x] === " ") {
1001
+ grid[rCurr][x] = "─";
1002
+ }
1003
+ }
1004
+ else if (rCurr < rNext) {
1005
+ // Going down (larger row index)
1006
+ if (grid[rCurr][x] === " ") {
1007
+ grid[rCurr][x] = "┐";
1008
+ }
1009
+ for (let y = rCurr + 1; y < rNext; y++) {
1010
+ if (grid[y][x] === " ") {
1011
+ grid[y][x] = "│";
1012
+ }
1013
+ }
1014
+ if (grid[rNext][x] === " ") {
1015
+ grid[rNext][x] = "└";
1016
+ }
1017
+ }
1018
+ else {
1019
+ // Going up (smaller row index)
1020
+ if (grid[rCurr][x] === " ") {
1021
+ grid[rCurr][x] = "┘";
1022
+ }
1023
+ for (let y = rNext + 1; y < rCurr; y++) {
1024
+ if (grid[y][x] === " ") {
1025
+ grid[y][x] = "│";
1026
+ }
1027
+ }
1028
+ if (grid[rNext][x] === " ") {
1029
+ grid[rNext][x] = "┌";
1030
+ }
932
1031
  }
933
1032
  }
934
- return cells.map(row => row.map(mask => mask === 0 ? " " : String.fromCharCode(0x2800 + mask)).join(""));
1033
+ return grid;
935
1034
  }
1035
+ const ESC = "\x1b";
1036
+ const RESET = `${ESC}[0m`;
1037
+ const BOLD = `${ESC}[1m`;
1038
+ const DIM = `${ESC}[2m`;
1039
+ const GRAY = `${ESC}[90m`;
936
1040
  function buildTrendXAxis(points, chartPoints, chartWidth) {
937
1041
  const labelChars = Array.from({ length: chartWidth }, () => " ");
1042
+ const tickChars = Array.from({ length: chartWidth }, () => "─");
938
1043
  const labelStep = Math.max(1, Math.ceil(points.length / 6));
939
1044
  for (let i = 0; i < chartPoints.length; i++) {
940
1045
  if (i % labelStep !== 0 && i !== chartPoints.length - 1)
941
1046
  continue;
942
1047
  const date = new Date(points[i][0]);
943
1048
  const label = `${date.getMonth() + 1}/${date.getDate()}`;
944
- const start = Math.min(Math.max(0, chartPoints[i].x - Math.floor(label.length / 2)), Math.max(0, chartWidth - label.length));
1049
+ const centerOfLabel = chartPoints[i].x;
1050
+ const start = Math.min(Math.max(0, centerOfLabel - Math.floor(label.length / 2)), Math.max(0, chartWidth - label.length));
945
1051
  const hasSpace = labelChars.slice(Math.max(0, start - 1), Math.min(chartWidth, start + label.length + 1)).every((c) => c === " ");
946
1052
  if (!hasSpace)
947
1053
  continue;
948
1054
  for (let j = 0; j < label.length; j++) {
949
1055
  labelChars[start + j] = label[j];
950
1056
  }
1057
+ if (centerOfLabel < chartWidth) {
1058
+ tickChars[centerOfLabel] = "┬";
1059
+ }
951
1060
  }
952
- return labelChars.join("");
1061
+ return {
1062
+ axisTicks: tickChars.join(""),
1063
+ axisLabels: labelChars.join("")
1064
+ };
953
1065
  }
954
1066
  function cmdTrend(flags) {
955
1067
  const days = parseInt(String(flagValue(flags, "days") ?? "30"), 10);
@@ -985,6 +1097,12 @@ function cmdTrend(flags) {
985
1097
  const values = sorted.map(([, d]) => getTrendValue(d, metric));
986
1098
  const maxVal = Math.max(...values, 1);
987
1099
  const minVal = Math.min(...values);
1100
+ const valRange = maxVal - minVal;
1101
+ // Adaptive scaling padding (e.g. 5% of range) to show beautiful ups & downs
1102
+ const pad = valRange === 0 ? maxVal * 0.1 : valRange * 0.05;
1103
+ const chartMax = maxVal + pad;
1104
+ const chartMin = Math.max(0, minVal - pad);
1105
+ const chartRange = chartMax - chartMin || 1;
988
1106
  const totalVal = values.reduce((sum, value) => sum + value, 0);
989
1107
  const avgVal = totalVal / values.length;
990
1108
  const deltaVal = values[values.length - 1] - values[0];
@@ -998,29 +1116,47 @@ function cmdTrend(flags) {
998
1116
  }
999
1117
  const chartWidth = Math.max(width - 12, 20);
1000
1118
  const yLabelStep = Math.max(1, Math.floor(chartHeight / 4));
1001
- const dotWidth = chartWidth * 2;
1002
- const dotHeight = chartHeight * 4;
1003
1119
  const chartPoints = values.map((value, i) => ({
1004
- x: values.length === 1 ? Math.floor(dotWidth / 2) : Math.round((i / (values.length - 1)) * (dotWidth - 1)),
1005
- y: Math.max(0, dotHeight - 1 - Math.round((value / maxVal) * (dotHeight - 1))),
1120
+ x: values.length === 1 ? Math.floor(chartWidth / 2) : Math.round((i / (values.length - 1)) * (chartWidth - 1)),
1121
+ y: Math.max(0, chartHeight - 1 - Math.round(((value - chartMin) / chartRange) * (chartHeight - 1))),
1006
1122
  }));
1007
- const rows = buildBrailleRows(chartPoints, chartWidth, chartHeight);
1008
- const labelPoints = chartPoints.map(point => ({ x: Math.floor(point.x / 2), y: point.y }));
1123
+ const grid = buildLineRows(chartPoints, chartWidth, chartHeight);
1124
+ // Metric premium color themes
1125
+ let metricColor = "\x1b[36m"; // default Cyan
1126
+ if (metric === "cost") {
1127
+ metricColor = "\x1b[32m"; // Green
1128
+ }
1129
+ else if (metric === "messages") {
1130
+ metricColor = "\x1b[33m"; // Yellow
1131
+ }
1132
+ const peakStr = `${metricColor}${BOLD}${formatTrendValue(maxVal, metric)}${RESET}`;
1133
+ const avgStr = `${metricColor}${BOLD}${formatTrendValue(avgVal, metric)}${RESET}`;
1134
+ let deltaColor = "\x1b[32m"; // Green
1135
+ if (metric === "cost") {
1136
+ deltaColor = deltaVal > 0 ? "\x1b[31m" : "\x1b[32m"; // Red for cost increase, Green for decrease
1137
+ }
1138
+ else {
1139
+ deltaColor = deltaVal > 0 ? "\x1b[32m" : "\x1b[31m"; // Green for token/msg increase, Red for decrease
1140
+ }
1141
+ const deltaStr = `${deltaColor}${BOLD}${formatSignedTrendValue(deltaVal, metric)}${RESET}`;
1009
1142
  const lines = [];
1010
- lines.push(`${metricLabel(metric)} · ${sorted.length} days · peak ${formatTrendValue(maxVal, metric)} · avg ${formatTrendValue(avgVal, metric)} · Δ ${formatSignedTrendValue(deltaVal, metric)}`);
1011
- lines.push(`range ${formatTrendValue(minVal, metric)} → ${formatTrendValue(maxVal, metric)}`);
1143
+ lines.push(`${metricColor}${BOLD}${metricLabel(metric)}${RESET} · ${sorted.length} days · peak ${peakStr} · avg ${avgStr} · Δ ${deltaStr}`);
1144
+ lines.push(`${GRAY}range ${formatTrendValue(minVal, metric)} → ${formatTrendValue(maxVal, metric)}${RESET}`);
1145
+ // Colorize the line chart points and connection characters
1146
+ const colorRows = grid.map(row => row.map(char => char === " " ? " " : `${metricColor}${char}${RESET}`).join(""));
1147
+ const { axisTicks, axisLabels } = buildTrendXAxis(sorted, chartPoints, chartWidth);
1012
1148
  for (let row = 0; row < chartHeight; row++) {
1013
1149
  const valueRatio = 1 - row / (chartHeight - 1);
1014
- const valAtRow = valueRatio * maxVal;
1015
- const label = row === 0 || row === chartHeight - 1 || row % yLabelStep === 0
1016
- ? formatTrendValue(valAtRow, metric)
1017
- : "";
1018
- const line = `${padLeft(label, 9)} ┤${rows[row]}`;
1150
+ const valAtRow = chartMin + valueRatio * chartRange;
1151
+ const hasLabel = row === 0 || row === chartHeight - 1 || row % yLabelStep === 0;
1152
+ const label = hasLabel ? formatTrendValue(valAtRow, metric) : "";
1153
+ const tick = hasLabel ? "┤" : "│";
1154
+ const line = `${padLeft(label, 9)} ${GRAY}${tick}${RESET}${colorRows[row]}`;
1019
1155
  lines.push(line);
1020
1156
  }
1021
- const axis = `${" ".repeat(9)} └${"─".repeat(chartWidth)}`;
1157
+ const axis = `${" ".repeat(9)} ${GRAY}└${axisTicks}${RESET}`;
1022
1158
  lines.push(axis);
1023
- lines.push(`${" ".repeat(11)}${buildTrendXAxis(sorted, labelPoints, chartWidth)}`);
1159
+ lines.push(`${" ".repeat(11)}${GRAY}${axisLabels}${RESET}`);
1024
1160
  console.log();
1025
1161
  for (const l of lines)
1026
1162
  console.log(` ${l}`);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,3 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
- type ProviderFamily = "anthropic" | "openai" | "deepseek" | "google" | "other";
3
- export declare function getProviderFamily(model: string, provider: string): ProviderFamily;
4
- export declare function calculateCost(model: string, provider: string, input: number, output: number, cacheRead?: number, cacheWrite?: number): number;
5
2
  export declare const TokenTrackerPlugin: Plugin;
6
3
  export default TokenTrackerPlugin;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
1
+ import { DEFAULT_CONFIG, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig, evaluateBudgetStatus, calculateCost } from "./lib/shared.js";
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
3
3
  import { open } from "fs/promises";
4
4
  import { join } from "path";
@@ -50,93 +50,6 @@ function ensureLatestConfig() {
50
50
  // Keep current config on error
51
51
  }
52
52
  }
53
- // ============================================================================
54
- // Pricing
55
- // ============================================================================
56
- function getModelPricing(model, provider) {
57
- // 1. Check provider-level override first (highest priority)
58
- if (config.providers[provider]) {
59
- return config.providers[provider];
60
- }
61
- // 2. Check user-defined model pricing (exact match only)
62
- const configuredPricing = findModelConfigPricing(config.models, model, provider, false);
63
- if (configuredPricing) {
64
- return configuredPricing;
65
- }
66
- // 3. Check built-in exact match
67
- if (BUILTIN_PRICING[model]) {
68
- return BUILTIN_PRICING[model];
69
- }
70
- // 4. Try partial match in built-in pricing
71
- const modelLower = model.toLowerCase();
72
- for (const [key, pricing] of Object.entries(BUILTIN_PRICING).sort(([a], [b]) => b.length - a.length)) {
73
- if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
74
- return pricing;
75
- }
76
- }
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
83
- return BUILTIN_PRICING["_default"];
84
- }
85
- export function getProviderFamily(model, provider) {
86
- const p = provider.toLowerCase();
87
- const m = model.toLowerCase();
88
- if (p.includes("anthropic") || m.startsWith("claude-")) {
89
- return "anthropic";
90
- }
91
- if (p.includes("openai") ||
92
- m.startsWith("gpt-") ||
93
- m.startsWith("o1-") ||
94
- m.startsWith("o3-") ||
95
- m.startsWith("o4-") ||
96
- m === "o3" ||
97
- m === "o1") {
98
- return "openai";
99
- }
100
- if (p.includes("deepseek") || m.includes("deepseek")) {
101
- return "deepseek";
102
- }
103
- if (p.includes("google") || p.includes("vertex") || m.startsWith("gemini-")) {
104
- return "google";
105
- }
106
- return "other";
107
- }
108
- export function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
109
- const pricing = getModelPricing(model, provider);
110
- const family = getProviderFamily(model, provider);
111
- let defaultCacheReadRate = 0.5; // Default 50% discount (OpenAI style)
112
- let defaultCacheWriteRate = 0; // Default free cache writing
113
- if (family === "anthropic") {
114
- defaultCacheReadRate = 0.1;
115
- defaultCacheWriteRate = 1.25;
116
- }
117
- else if (family === "deepseek" || family === "google") {
118
- defaultCacheReadRate = 0.1;
119
- defaultCacheWriteRate = 0;
120
- }
121
- else if (family === "openai") {
122
- defaultCacheReadRate = 0.5;
123
- defaultCacheWriteRate = 0;
124
- }
125
- else {
126
- // "other" / general default
127
- defaultCacheReadRate = 0.5;
128
- defaultCacheWriteRate = 0;
129
- }
130
- const finalCacheReadPrice = pricing.cacheRead ?? (pricing.input * defaultCacheReadRate);
131
- const finalCacheWritePrice = pricing.cacheWrite ?? (pricing.input * defaultCacheWriteRate);
132
- // Billable input = total input - cache read (cached tokens are charged at cache rate)
133
- const billableInput = Math.max(0, input - cacheRead);
134
- const inputCost = (billableInput / 1_000_000) * pricing.input;
135
- const outputCost = (output / 1_000_000) * pricing.output;
136
- const cacheReadCost = (cacheRead / 1_000_000) * finalCacheReadPrice;
137
- const cacheWriteCost = (cacheWrite / 1_000_000) * finalCacheWritePrice;
138
- return inputCost + outputCost + cacheReadCost + cacheWriteCost;
139
- }
140
53
  const sessionStats = new Map();
141
54
  function getOrCreateSessionStats(sessionId) {
142
55
  if (!sessionStats.has(sessionId)) {
@@ -387,48 +300,12 @@ function accumulateBudget(cost) {
387
300
  budgetTracker.monthlySpent += cost;
388
301
  }
389
302
  function checkBudgetStatus() {
390
- const budget = config.budget;
391
- if (!budget.daily && !budget.weekly && !budget.monthly) {
392
- return null;
393
- }
394
- if (!budgetTracker.initialized)
395
- return null;
396
- const warnAt = budget.warnAt ?? 0.8;
397
- // Check in order: daily -> weekly -> monthly (most restrictive first)
398
- if (budget.daily) {
399
- const percentage = budgetTracker.dailySpent / budget.daily;
400
- return {
401
- period: "daily",
402
- spent: budgetTracker.dailySpent,
403
- limit: budget.daily,
404
- percentage,
405
- exceeded: percentage >= 1,
406
- warning: percentage >= warnAt && percentage < 1,
407
- };
408
- }
409
- if (budget.weekly) {
410
- const percentage = budgetTracker.weeklySpent / budget.weekly;
411
- return {
412
- period: "weekly",
413
- spent: budgetTracker.weeklySpent,
414
- limit: budget.weekly,
415
- percentage,
416
- exceeded: percentage >= 1,
417
- warning: percentage >= warnAt && percentage < 1,
418
- };
419
- }
420
- if (budget.monthly) {
421
- const percentage = budgetTracker.monthlySpent / budget.monthly;
422
- return {
423
- period: "monthly",
424
- spent: budgetTracker.monthlySpent,
425
- limit: budget.monthly,
426
- percentage,
427
- exceeded: percentage >= 1,
428
- warning: percentage >= warnAt && percentage < 1,
429
- };
430
- }
431
- return null;
303
+ const snapshot = {
304
+ dailySpent: budgetTracker.dailySpent,
305
+ weeklySpent: budgetTracker.weeklySpent,
306
+ monthlySpent: budgetTracker.monthlySpent,
307
+ };
308
+ return evaluateBudgetStatus(config.budget, snapshot, budgetTracker.initialized);
432
309
  }
433
310
  function formatBudgetMessage(status) {
434
311
  const pct = Math.round(status.percentage * 100);
@@ -497,7 +374,7 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
497
374
  return;
498
375
  const model = info.model?.modelID ?? info.modelID ?? "unknown";
499
376
  const provider = info.model?.providerID ?? info.providerID ?? "unknown";
500
- const cost = calculateCost(model, provider, input, output, cacheRead, cacheWrite);
377
+ const cost = calculateCost(model, provider, input, output, cacheRead, cacheWrite, config);
501
378
  // Update session stats
502
379
  const stats = getOrCreateSessionStats(sessionId);
503
380
  stats.totalInput += input;