@diogonzafe/tokenwatch 0.5.0 → 0.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/dist/cli.js CHANGED
@@ -1,4 +1,286 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/adapters/postgres.ts
13
+ var postgres_exports = {};
14
+ __export(postgres_exports, {
15
+ PostgresStorage: () => PostgresStorage
16
+ });
17
+ function rowToEntry(r) {
18
+ const reasoningTokens = r["reasoning_tokens"] ?? 0;
19
+ const cachedTokens = r["cached_tokens"] ?? 0;
20
+ const cacheCreationTokens = r["cache_creation_tokens"] ?? 0;
21
+ return {
22
+ model: r["model"],
23
+ inputTokens: r["input_tokens"],
24
+ outputTokens: r["output_tokens"],
25
+ ...reasoningTokens > 0 && { reasoningTokens },
26
+ ...cachedTokens > 0 && { cachedTokens },
27
+ ...cacheCreationTokens > 0 && { cacheCreationTokens },
28
+ costUSD: Number(r["cost_usd"]),
29
+ ...r["session_id"] != null && { sessionId: r["session_id"] },
30
+ ...r["user_id"] != null && { userId: r["user_id"] },
31
+ ...r["feature"] != null && { feature: r["feature"] },
32
+ timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
33
+ };
34
+ }
35
+ var PostgresStorage;
36
+ var init_postgres = __esm({
37
+ "src/adapters/postgres.ts"() {
38
+ "use strict";
39
+ PostgresStorage = class {
40
+ constructor(client) {
41
+ this.client = client;
42
+ }
43
+ client;
44
+ /** Creates the `tokenwatch_usage` table if it does not already exist.
45
+ * Also adds new columns for databases created before v0.2.0 / v0.3.0. */
46
+ async migrate() {
47
+ await this.client.query(`
48
+ CREATE TABLE IF NOT EXISTS tokenwatch_usage (
49
+ id BIGSERIAL PRIMARY KEY,
50
+ model TEXT NOT NULL,
51
+ input_tokens INTEGER NOT NULL,
52
+ output_tokens INTEGER NOT NULL,
53
+ reasoning_tokens INTEGER NOT NULL DEFAULT 0,
54
+ cached_tokens INTEGER NOT NULL DEFAULT 0,
55
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
56
+ cost_usd NUMERIC NOT NULL,
57
+ session_id TEXT,
58
+ user_id TEXT,
59
+ feature TEXT,
60
+ timestamp TIMESTAMPTZ NOT NULL
61
+ )
62
+ `);
63
+ for (const col of [
64
+ "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS reasoning_tokens INTEGER NOT NULL DEFAULT 0",
65
+ "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS feature TEXT",
66
+ "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cached_tokens INTEGER NOT NULL DEFAULT 0",
67
+ "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cache_creation_tokens INTEGER NOT NULL DEFAULT 0"
68
+ ]) {
69
+ await this.client.query(col).catch(() => {
70
+ });
71
+ }
72
+ }
73
+ record(entry) {
74
+ this.client.query(
75
+ `INSERT INTO tokenwatch_usage
76
+ (model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
77
+ cost_usd, session_id, user_id, feature, timestamp)
78
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
79
+ [
80
+ entry.model,
81
+ entry.inputTokens,
82
+ entry.outputTokens,
83
+ entry.reasoningTokens ?? 0,
84
+ entry.cachedTokens ?? 0,
85
+ entry.cacheCreationTokens ?? 0,
86
+ entry.costUSD,
87
+ entry.sessionId ?? null,
88
+ entry.userId ?? null,
89
+ entry.feature ?? null,
90
+ entry.timestamp
91
+ ]
92
+ ).catch((err) => {
93
+ console.warn("[tokenwatch] PostgresStorage.record failed:", err);
94
+ });
95
+ }
96
+ async getAll() {
97
+ const result = await this.client.query(
98
+ "SELECT * FROM tokenwatch_usage ORDER BY timestamp ASC"
99
+ );
100
+ return result.rows.map(rowToEntry);
101
+ }
102
+ async clearAll() {
103
+ await this.client.query("DELETE FROM tokenwatch_usage");
104
+ }
105
+ async clearSession(sessionId) {
106
+ await this.client.query(
107
+ "DELETE FROM tokenwatch_usage WHERE session_id = $1",
108
+ [sessionId]
109
+ );
110
+ }
111
+ };
112
+ }
113
+ });
114
+
115
+ // src/adapters/mysql.ts
116
+ var mysql_exports = {};
117
+ __export(mysql_exports, {
118
+ MySQLStorage: () => MySQLStorage
119
+ });
120
+ function rowToEntry2(r) {
121
+ const reasoningTokens = r["reasoning_tokens"] ?? 0;
122
+ const cachedTokens = r["cached_tokens"] ?? 0;
123
+ const cacheCreationTokens = r["cache_creation_tokens"] ?? 0;
124
+ return {
125
+ model: r["model"],
126
+ inputTokens: r["input_tokens"],
127
+ outputTokens: r["output_tokens"],
128
+ ...reasoningTokens > 0 && { reasoningTokens },
129
+ ...cachedTokens > 0 && { cachedTokens },
130
+ ...cacheCreationTokens > 0 && { cacheCreationTokens },
131
+ costUSD: Number(r["cost_usd"]),
132
+ ...r["session_id"] != null && { sessionId: r["session_id"] },
133
+ ...r["user_id"] != null && { userId: r["user_id"] },
134
+ ...r["feature"] != null && { feature: r["feature"] },
135
+ timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
136
+ };
137
+ }
138
+ var MySQLStorage;
139
+ var init_mysql = __esm({
140
+ "src/adapters/mysql.ts"() {
141
+ "use strict";
142
+ MySQLStorage = class {
143
+ constructor(client) {
144
+ this.client = client;
145
+ }
146
+ client;
147
+ /** Creates the `tokenwatch_usage` table if it does not already exist.
148
+ * Also adds new columns for databases created before v0.2.0 / v0.3.0. */
149
+ async migrate() {
150
+ await this.client.execute(`
151
+ CREATE TABLE IF NOT EXISTS tokenwatch_usage (
152
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
153
+ model VARCHAR(255) NOT NULL,
154
+ input_tokens INT NOT NULL,
155
+ output_tokens INT NOT NULL,
156
+ reasoning_tokens INT NOT NULL DEFAULT 0,
157
+ cached_tokens INT NOT NULL DEFAULT 0,
158
+ cache_creation_tokens INT NOT NULL DEFAULT 0,
159
+ cost_usd DECIMAL(18,8) NOT NULL,
160
+ session_id VARCHAR(255),
161
+ user_id VARCHAR(255),
162
+ feature VARCHAR(255),
163
+ timestamp DATETIME(3) NOT NULL
164
+ )
165
+ `);
166
+ await this.client.execute(`
167
+ ALTER TABLE tokenwatch_usage
168
+ ADD COLUMN IF NOT EXISTS reasoning_tokens INT NOT NULL DEFAULT 0,
169
+ ADD COLUMN IF NOT EXISTS feature VARCHAR(255),
170
+ ADD COLUMN IF NOT EXISTS cached_tokens INT NOT NULL DEFAULT 0,
171
+ ADD COLUMN IF NOT EXISTS cache_creation_tokens INT NOT NULL DEFAULT 0
172
+ `).catch(() => {
173
+ });
174
+ }
175
+ record(entry) {
176
+ this.client.execute(
177
+ `INSERT INTO tokenwatch_usage
178
+ (model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
179
+ cost_usd, session_id, user_id, feature, timestamp)
180
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
181
+ [
182
+ entry.model,
183
+ entry.inputTokens,
184
+ entry.outputTokens,
185
+ entry.reasoningTokens ?? 0,
186
+ entry.cachedTokens ?? 0,
187
+ entry.cacheCreationTokens ?? 0,
188
+ entry.costUSD,
189
+ entry.sessionId ?? null,
190
+ entry.userId ?? null,
191
+ entry.feature ?? null,
192
+ entry.timestamp
193
+ ]
194
+ ).catch((err) => {
195
+ console.warn("[tokenwatch] MySQLStorage.record failed:", err);
196
+ });
197
+ }
198
+ async getAll() {
199
+ const [rows] = await this.client.execute(
200
+ "SELECT * FROM tokenwatch_usage ORDER BY timestamp ASC"
201
+ );
202
+ return rows.map(rowToEntry2);
203
+ }
204
+ async clearAll() {
205
+ await this.client.execute("DELETE FROM tokenwatch_usage");
206
+ }
207
+ async clearSession(sessionId) {
208
+ await this.client.execute(
209
+ "DELETE FROM tokenwatch_usage WHERE session_id = ?",
210
+ [sessionId]
211
+ );
212
+ }
213
+ };
214
+ }
215
+ });
216
+
217
+ // src/adapters/mongodb.ts
218
+ var mongodb_exports = {};
219
+ __export(mongodb_exports, {
220
+ MongoStorage: () => MongoStorage
221
+ });
222
+ function docToEntry(doc) {
223
+ return {
224
+ model: doc.model,
225
+ inputTokens: doc.inputTokens,
226
+ outputTokens: doc.outputTokens,
227
+ ...doc.reasoningTokens != null && doc.reasoningTokens > 0 && { reasoningTokens: doc.reasoningTokens },
228
+ ...doc.cachedTokens != null && doc.cachedTokens > 0 && { cachedTokens: doc.cachedTokens },
229
+ ...doc.cacheCreationTokens != null && doc.cacheCreationTokens > 0 && { cacheCreationTokens: doc.cacheCreationTokens },
230
+ costUSD: doc.costUSD,
231
+ ...doc.sessionId != null && { sessionId: doc.sessionId },
232
+ ...doc.userId != null && { userId: doc.userId },
233
+ ...doc.feature != null && { feature: doc.feature },
234
+ timestamp: doc.timestamp
235
+ };
236
+ }
237
+ var COLLECTION, MongoStorage;
238
+ var init_mongodb = __esm({
239
+ "src/adapters/mongodb.ts"() {
240
+ "use strict";
241
+ COLLECTION = "tokenwatch_usage";
242
+ MongoStorage = class {
243
+ col;
244
+ constructor(db) {
245
+ this.col = db.collection(COLLECTION);
246
+ }
247
+ /** Creates recommended indexes for query performance. Call once at startup. */
248
+ async createIndexes() {
249
+ await this.col.createIndex({ timestamp: 1 });
250
+ await this.col.createIndex({ sessionId: 1 });
251
+ await this.col.createIndex({ userId: 1 });
252
+ await this.col.createIndex({ model: 1 });
253
+ }
254
+ record(entry) {
255
+ this.col.insertOne({
256
+ model: entry.model,
257
+ inputTokens: entry.inputTokens,
258
+ outputTokens: entry.outputTokens,
259
+ ...entry.reasoningTokens !== void 0 && { reasoningTokens: entry.reasoningTokens },
260
+ ...entry.cachedTokens !== void 0 && { cachedTokens: entry.cachedTokens },
261
+ ...entry.cacheCreationTokens !== void 0 && { cacheCreationTokens: entry.cacheCreationTokens },
262
+ costUSD: entry.costUSD,
263
+ sessionId: entry.sessionId ?? null,
264
+ userId: entry.userId ?? null,
265
+ ...entry.feature !== void 0 && { feature: entry.feature },
266
+ timestamp: entry.timestamp
267
+ }).catch((err) => {
268
+ console.warn("[tokenwatch] MongoStorage.record failed:", err);
269
+ });
270
+ }
271
+ async getAll() {
272
+ const docs = await this.col.find({}).sort({ timestamp: 1 }).toArray();
273
+ return docs.map(docToEntry);
274
+ }
275
+ async clearAll() {
276
+ await this.col.deleteMany({});
277
+ }
278
+ async clearSession(sessionId) {
279
+ await this.col.deleteMany({ sessionId });
280
+ }
281
+ };
282
+ }
283
+ });
2
284
 
3
285
  // bin/cli.ts
4
286
  import { readFileSync, existsSync as existsSync2 } from "fs";
@@ -245,7 +527,7 @@ function maybeSuggestCheaperModel(model, costUSD, inputTokens, outputTokens, lay
245
527
 
246
528
  // prices.json
247
529
  var prices_default = {
248
- updated_at: "2026-04-22",
530
+ updated_at: "2026-04-24",
249
531
  source: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
250
532
  models: {
251
533
  "gpt-4o": {
@@ -1562,6 +1844,12 @@ var prices_default = {
1562
1844
  cachedInput: 0.3,
1563
1845
  cacheCreationInput: 3.75,
1564
1846
  maxInputTokens: 1e6
1847
+ },
1848
+ "gpt-5.5": {
1849
+ input: 5,
1850
+ output: 30,
1851
+ cachedInput: 0.5,
1852
+ maxInputTokens: 272e3
1565
1853
  }
1566
1854
  }
1567
1855
  };
@@ -1594,7 +1882,14 @@ var TrackerConfigSchema = z.object({
1594
1882
  perUser: BudgetConfigSchema.optional(),
1595
1883
  perSession: BudgetConfigSchema.optional()
1596
1884
  }).optional(),
1597
- suggestions: z.boolean().optional().default(false)
1885
+ suggestions: z.boolean().optional().default(false),
1886
+ anomalyDetection: z.object({
1887
+ multiplierThreshold: z.number().positive(),
1888
+ webhookUrl: z.string().url(),
1889
+ windowHours: z.number().positive().optional().default(24),
1890
+ mode: z.enum(["once", "always"]).optional().default("once")
1891
+ }).optional(),
1892
+ exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional()
1598
1893
  });
1599
1894
  function createTracker(config = {}) {
1600
1895
  const parsed = TrackerConfigSchema.safeParse(config);
@@ -1611,7 +1906,9 @@ ${issues}`);
1611
1906
  customPrices,
1612
1907
  warnIfStaleAfterHours,
1613
1908
  budgets,
1614
- suggestions
1909
+ suggestions,
1910
+ anomalyDetection,
1911
+ exporter
1615
1912
  } = parsed.data;
1616
1913
  const storage = typeof storageOption === "object" ? storageOption : createStorage(storageOption);
1617
1914
  let remotePrices;
@@ -1644,6 +1941,7 @@ ${issues}`);
1644
1941
  let alertFired = false;
1645
1942
  const firedUserAlerts = /* @__PURE__ */ new Set();
1646
1943
  const firedSessionAlerts = /* @__PURE__ */ new Set();
1944
+ const firedAnomalyKeys = /* @__PURE__ */ new Set();
1647
1945
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1648
1946
  function resolveModelPrice(model) {
1649
1947
  maybeWarnStaleness();
@@ -1668,7 +1966,12 @@ ${issues}`);
1668
1966
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1669
1967
  };
1670
1968
  storage.record(full);
1969
+ if (exporter) {
1970
+ Promise.resolve(exporter.export(full)).catch(() => {
1971
+ });
1972
+ }
1671
1973
  maybeFireAlerts(full);
1974
+ if (anomalyDetection) maybeDetectAnomaly(full);
1672
1975
  if (suggestions) {
1673
1976
  maybeSuggestCheaperModel(entry.model, costUSD, entry.inputTokens, entry.outputTokens, {
1674
1977
  bundledPrices,
@@ -1838,11 +2141,56 @@ ${issues}`);
1838
2141
  basedOnPeriod: { from: first, to: last }
1839
2142
  };
1840
2143
  }
2144
+ function maybeDetectAnomaly(entry) {
2145
+ if (entry.costUSD <= 0) return;
2146
+ const { multiplierThreshold, webhookUrl: aUrl, windowHours: wh, mode: modeRaw } = anomalyDetection;
2147
+ const wHours = wh ?? 24;
2148
+ const mode = modeRaw ?? "once";
2149
+ const windowStart = Date.now() - wHours * 60 * 60 * 1e3;
2150
+ const entryTs = new Date(entry.timestamp).getTime();
2151
+ function checkEntity(key, label, predicate) {
2152
+ if (mode !== "always" && firedAnomalyKeys.has(key)) return;
2153
+ if (mode !== "always") firedAnomalyKeys.add(key);
2154
+ Promise.resolve(storage.getAll()).then((all) => {
2155
+ const history = all.filter(
2156
+ (e) => predicate(e) && new Date(e.timestamp).getTime() >= windowStart && new Date(e.timestamp).getTime() !== entryTs
2157
+ );
2158
+ if (history.length === 0) {
2159
+ if (mode !== "always") firedAnomalyKeys.delete(key);
2160
+ return;
2161
+ }
2162
+ const avg = history.reduce((s, e) => s + e.costUSD, 0) / history.length;
2163
+ if (avg <= 0 || entry.costUSD <= avg * multiplierThreshold) {
2164
+ if (mode !== "always") firedAnomalyKeys.delete(key);
2165
+ return;
2166
+ }
2167
+ const multiple = (entry.costUSD / avg).toFixed(1);
2168
+ fireWebhook(aUrl, {
2169
+ text: `[tokenwatch] Anomaly: ${label} call cost $${entry.costUSD.toFixed(4)} is ${multiple}x above ${wHours}h average ($${avg.toFixed(4)})`
2170
+ });
2171
+ }).catch(() => {
2172
+ if (mode !== "always") firedAnomalyKeys.delete(key);
2173
+ });
2174
+ }
2175
+ if (entry.userId) {
2176
+ checkEntity(
2177
+ `user:${entry.userId}`,
2178
+ `user "${entry.userId}"`,
2179
+ (e) => e.userId === entry.userId
2180
+ );
2181
+ }
2182
+ checkEntity(
2183
+ `model:${entry.model}`,
2184
+ `model "${entry.model}"`,
2185
+ (e) => e.model === entry.model
2186
+ );
2187
+ }
1841
2188
  async function reset() {
1842
2189
  await Promise.resolve(storage.clearAll());
1843
2190
  alertFired = false;
1844
2191
  firedUserAlerts.clear();
1845
2192
  firedSessionAlerts.clear();
2193
+ firedAnomalyKeys.clear();
1846
2194
  }
1847
2195
  async function resetSession(sessionId) {
1848
2196
  await Promise.resolve(storage.clearSession(sessionId));
@@ -2686,7 +3034,82 @@ function startDashboardServer(storage, port) {
2686
3034
 
2687
3035
  // bin/cli.ts
2688
3036
  var __dirname = dirname(fileURLToPath(import.meta.url));
2689
- var DB_PATH2 = join3(homedir3(), ".tokenwatch", "usage.db");
3037
+ var DEFAULT_DB_PATH = join3(homedir3(), ".tokenwatch", "usage.db");
3038
+ function getFlag(args, flag) {
3039
+ const idx = args.indexOf(flag);
3040
+ if (idx === -1) return void 0;
3041
+ const value = args[idx + 1];
3042
+ return value !== void 0 && !value.startsWith("--") ? value : void 0;
3043
+ }
3044
+ async function openStorage(dbUrl) {
3045
+ if (!dbUrl) {
3046
+ if (!existsSync2(DEFAULT_DB_PATH)) {
3047
+ console.error(`No SQLite database found at ${DEFAULT_DB_PATH}`);
3048
+ console.error("Start your app with storage: 'sqlite' to begin recording usage.");
3049
+ console.error("Or pass --db <url> to connect to Postgres, MySQL, or MongoDB.");
3050
+ process.exit(1);
3051
+ }
3052
+ let storage;
3053
+ try {
3054
+ storage = new SqliteStorage(DEFAULT_DB_PATH);
3055
+ } catch {
3056
+ console.error("Failed to open SQLite database. Is better-sqlite3 installed?");
3057
+ console.error("Run: npm install better-sqlite3");
3058
+ process.exit(1);
3059
+ }
3060
+ return { storage, close: async () => {
3061
+ } };
3062
+ }
3063
+ if (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://")) {
3064
+ let pgMod;
3065
+ try {
3066
+ pgMod = (await import("pg")).default;
3067
+ } catch {
3068
+ console.error("[tokenwatch] Postgres requires the pg package.");
3069
+ console.error("Run: npm install pg");
3070
+ process.exit(1);
3071
+ }
3072
+ const { PostgresStorage: PostgresStorage2 } = await Promise.resolve().then(() => (init_postgres(), postgres_exports));
3073
+ const pool = new pgMod.Pool({ connectionString: dbUrl });
3074
+ const storage = new PostgresStorage2(pool);
3075
+ return { storage, close: () => pool.end() };
3076
+ }
3077
+ if (dbUrl.startsWith("mysql://")) {
3078
+ let mysqlMod;
3079
+ try {
3080
+ mysqlMod = await import("mysql2/promise");
3081
+ } catch {
3082
+ console.error("[tokenwatch] MySQL requires the mysql2 package.");
3083
+ console.error("Run: npm install mysql2");
3084
+ process.exit(1);
3085
+ }
3086
+ const { MySQLStorage: MySQLStorage2 } = await Promise.resolve().then(() => (init_mysql(), mysql_exports));
3087
+ const pool = mysqlMod.createPool(dbUrl);
3088
+ const storage = new MySQLStorage2(pool);
3089
+ return { storage, close: () => pool.end() };
3090
+ }
3091
+ if (dbUrl.startsWith("mongodb://") || dbUrl.startsWith("mongodb+srv://")) {
3092
+ let mongoMod;
3093
+ try {
3094
+ mongoMod = await import("mongodb");
3095
+ } catch {
3096
+ console.error("[tokenwatch] MongoDB requires the mongodb package.");
3097
+ console.error("Run: npm install mongodb");
3098
+ process.exit(1);
3099
+ }
3100
+ const { MongoStorage: MongoStorage2 } = await Promise.resolve().then(() => (init_mongodb(), mongodb_exports));
3101
+ const urlObj = new URL(dbUrl);
3102
+ const dbName = urlObj.pathname.replace(/^\//, "") || "tokenwatch";
3103
+ const client = new mongoMod.MongoClient(dbUrl);
3104
+ await client.connect();
3105
+ const db = client.db(dbName);
3106
+ const storage = new MongoStorage2(db);
3107
+ return { storage, close: () => client.close() };
3108
+ }
3109
+ console.error(`[tokenwatch] Unsupported database URL: "${dbUrl}"`);
3110
+ console.error("Supported protocols: postgres://, mysql://, mongodb://, mongodb+srv://");
3111
+ process.exit(1);
3112
+ }
2690
3113
  function loadBundledPrices() {
2691
3114
  const pricesPath = join3(__dirname, "..", "prices.json");
2692
3115
  const raw = readFileSync(pricesPath, "utf8");
@@ -2719,22 +3142,16 @@ function cmdPrices() {
2719
3142
  console.log(`${row.model.padEnd(maxName)} ${row.input.padStart(12)} ${row.output.padStart(12)}`);
2720
3143
  }
2721
3144
  }
2722
- async function cmdReport() {
2723
- if (!existsSync2(DB_PATH2)) {
2724
- console.log(`No SQLite database found at ${DB_PATH2}`);
2725
- console.log("Start your app with storage: 'sqlite' to begin recording usage.");
2726
- return;
2727
- }
2728
- let storage;
3145
+ async function cmdReport(args) {
3146
+ const dbUrl = getFlag(args, "--db");
3147
+ const { storage, close } = await openStorage(dbUrl);
3148
+ let report;
2729
3149
  try {
2730
- storage = new SqliteStorage(DB_PATH2);
2731
- } catch {
2732
- console.error("Failed to open SQLite database. Is better-sqlite3 installed?");
2733
- console.error("Run: npm install better-sqlite3");
2734
- process.exit(1);
3150
+ const tracker = createTracker({ storage, syncPrices: false });
3151
+ report = await tracker.getReport();
3152
+ } finally {
3153
+ await close();
2735
3154
  }
2736
- const tracker = createTracker({ storage, syncPrices: false });
2737
- const report = await tracker.getReport();
2738
3155
  if (report.totalCostUSD === 0 && Object.keys(report.byModel).length === 0) {
2739
3156
  console.log("No usage recorded yet.");
2740
3157
  return;
@@ -2772,20 +3189,20 @@ async function cmdReport() {
2772
3189
  }
2773
3190
  console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
2774
3191
  }
2775
- async function cmdDashboard(port) {
2776
- if (!existsSync2(DB_PATH2)) {
2777
- console.log(`No SQLite database found at ${DB_PATH2}`);
2778
- console.log("Start your app with storage: 'sqlite' to begin recording usage.");
2779
- process.exit(1);
2780
- }
2781
- let storage;
2782
- try {
2783
- storage = new SqliteStorage(DB_PATH2);
2784
- } catch {
2785
- console.error("Failed to open SQLite database. Is better-sqlite3 installed?");
2786
- console.error("Run: npm install better-sqlite3");
3192
+ async function cmdDashboard(args) {
3193
+ const portFlag = getFlag(args, "--port");
3194
+ const port = portFlag !== void 0 ? parseInt(portFlag, 10) : 4242;
3195
+ if (isNaN(port) || port < 1 || port > 65535) {
3196
+ console.error(`[tokenwatch] Invalid port: "${portFlag}". Must be a number between 1 and 65535.`);
2787
3197
  process.exit(1);
2788
3198
  }
3199
+ const dbUrl = getFlag(args, "--db");
3200
+ const { storage, close } = await openStorage(dbUrl);
3201
+ const shutdown = () => {
3202
+ void close().then(() => process.exit(0));
3203
+ };
3204
+ process.on("SIGINT", shutdown);
3205
+ process.on("SIGTERM", shutdown);
2789
3206
  startDashboardServer(storage, port);
2790
3207
  }
2791
3208
  function cmdHelp() {
@@ -2793,11 +3210,23 @@ function cmdHelp() {
2793
3210
  tokenwatch \u2014 CLI
2794
3211
 
2795
3212
  Commands:
2796
- sync Fetch and cache latest model prices from remote
2797
- prices List all bundled models and their current prices
2798
- report Show last saved usage report (requires SQLite storage)
2799
- dashboard [--port N] Open local web dashboard (default port: 4242)
2800
- help Show this help message
3213
+ sync Fetch and cache latest model prices from remote
3214
+ prices List all bundled models and their current prices
3215
+ report [--db <url>] Show usage report (default: SQLite at ~/.tokenwatch/usage.db)
3216
+ dashboard [--port N] Open local web dashboard (default port: 4242)
3217
+ [--db <url>] Connect to a database instead of the default SQLite
3218
+
3219
+ Database URL formats:
3220
+ (none) ~/.tokenwatch/usage.db (SQLite, default)
3221
+ postgres://user:pass@host:5432/dbname
3222
+ mysql://user:pass@host:3306/dbname
3223
+ mongodb://user:pass@host:27017/dbname
3224
+
3225
+ Examples:
3226
+ tokenwatch dashboard
3227
+ tokenwatch dashboard --port 8080
3228
+ tokenwatch dashboard --db postgres://user:pass@localhost:5432/myapp
3229
+ tokenwatch report --db mysql://root:pass@localhost:3306/myapp
2801
3230
  `.trim());
2802
3231
  }
2803
3232
  async function main() {
@@ -2810,14 +3239,11 @@ async function main() {
2810
3239
  cmdPrices();
2811
3240
  break;
2812
3241
  case "report":
2813
- await cmdReport();
3242
+ await cmdReport(args);
2814
3243
  break;
2815
- case "dashboard": {
2816
- const portFlagIdx = args.indexOf("--port");
2817
- const port = portFlagIdx !== -1 ? parseInt(args[portFlagIdx + 1] ?? "4242", 10) : 4242;
2818
- await cmdDashboard(port);
3244
+ case "dashboard":
3245
+ await cmdDashboard(args);
2819
3246
  break;
2820
- }
2821
3247
  case "help":
2822
3248
  case void 0:
2823
3249
  cmdHelp();