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.
- package/README.md +32 -10
- package/README.zh-CN.md +106 -9
- package/dist/bin/opencode-tokens.js +231 -95
- package/dist/index.d.ts +0 -3
- package/dist/index.js +8 -131
- package/dist/lib/shared.d.ts +25 -0
- package/dist/lib/shared.js +156 -4
- package/package.json +7 -3
- package/dist/test/cli.test.d.ts +0 -1
- package/dist/test/cli.test.js +0 -55
- package/dist/test/shared.test.d.ts +0 -1
- package/dist/test/shared.test.js +0 -536
|
@@ -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,
|
|
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)
|
|
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
|
-
|
|
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
|
|
414
|
+
const modelProviders = new Set();
|
|
429
415
|
for (const e of entries) {
|
|
430
416
|
if (e.provider)
|
|
431
417
|
providers.add(e.provider);
|
|
432
|
-
|
|
433
|
-
|
|
418
|
+
const model = e.model ?? "unknown";
|
|
419
|
+
const provider = e.provider ?? "unknown";
|
|
420
|
+
modelProviders.add(`${model}|${provider}`);
|
|
434
421
|
}
|
|
435
|
-
//
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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:
|
|
452
|
-
weekly:
|
|
453
|
-
monthly:
|
|
440
|
+
daily: dailyLimit,
|
|
441
|
+
weekly: weeklyLimit,
|
|
442
|
+
monthly: monthlyLimit,
|
|
454
443
|
warnAt: 0.8,
|
|
455
444
|
},
|
|
456
445
|
};
|
|
457
|
-
|
|
446
|
+
const suggestedProviders = [];
|
|
447
|
+
// Add providers as comments/examples (Common free providers)
|
|
458
448
|
for (const provider of providers) {
|
|
459
|
-
|
|
460
|
-
if (
|
|
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
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
1005
|
-
y: Math.max(0,
|
|
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
|
|
1008
|
-
|
|
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 ${
|
|
1011
|
-
lines.push(
|
|
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 *
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const line = `${padLeft(label, 9)}
|
|
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)} └${
|
|
1157
|
+
const axis = `${" ".repeat(9)} ${GRAY}└${axisTicks}${RESET}`;
|
|
1022
1158
|
lines.push(axis);
|
|
1023
|
-
lines.push(`${" ".repeat(11)}${
|
|
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 {
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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;
|