@diogonzafe/tokenwatch 0.8.0 → 0.10.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
@@ -30,6 +30,7 @@ function rowToEntry(r) {
30
30
  ...r["user_id"] != null && { userId: r["user_id"] },
31
31
  ...r["feature"] != null && { feature: r["feature"] },
32
32
  ...r["app_id"] != null && { appId: r["app_id"] },
33
+ ...r["metadata"] != null && { metadata: r["metadata"] },
33
34
  timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
34
35
  };
35
36
  }
@@ -59,6 +60,7 @@ var init_postgres = __esm({
59
60
  user_id TEXT,
60
61
  feature TEXT,
61
62
  app_id TEXT,
63
+ metadata JSONB,
62
64
  timestamp TIMESTAMPTZ NOT NULL
63
65
  )
64
66
  `);
@@ -67,7 +69,8 @@ var init_postgres = __esm({
67
69
  "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS feature TEXT",
68
70
  "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cached_tokens INTEGER NOT NULL DEFAULT 0",
69
71
  "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS cache_creation_tokens INTEGER NOT NULL DEFAULT 0",
70
- "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS app_id TEXT"
72
+ "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS app_id TEXT",
73
+ "ALTER TABLE tokenwatch_usage ADD COLUMN IF NOT EXISTS metadata JSONB"
71
74
  ]) {
72
75
  await this.client.query(col).catch(() => {
73
76
  });
@@ -77,8 +80,8 @@ var init_postgres = __esm({
77
80
  this.client.query(
78
81
  `INSERT INTO tokenwatch_usage
79
82
  (model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
80
- cost_usd, session_id, user_id, feature, app_id, timestamp)
81
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
83
+ cost_usd, session_id, user_id, feature, app_id, metadata, timestamp)
84
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
82
85
  [
83
86
  entry.model,
84
87
  entry.inputTokens,
@@ -91,6 +94,7 @@ var init_postgres = __esm({
91
94
  entry.userId ?? null,
92
95
  entry.feature ?? null,
93
96
  entry.appId ?? null,
97
+ entry.metadata ?? null,
94
98
  entry.timestamp
95
99
  ]
96
100
  ).catch((err) => {
@@ -137,6 +141,7 @@ function rowToEntry2(r) {
137
141
  ...r["user_id"] != null && { userId: r["user_id"] },
138
142
  ...r["feature"] != null && { feature: r["feature"] },
139
143
  ...r["app_id"] != null && { appId: r["app_id"] },
144
+ ...r["metadata"] != null && { metadata: typeof r["metadata"] === "string" ? JSON.parse(r["metadata"]) : r["metadata"] },
140
145
  timestamp: r["timestamp"] instanceof Date ? r["timestamp"].toISOString() : r["timestamp"]
141
146
  };
142
147
  }
@@ -166,6 +171,7 @@ var init_mysql = __esm({
166
171
  user_id VARCHAR(255),
167
172
  feature VARCHAR(255),
168
173
  app_id VARCHAR(255),
174
+ metadata JSON,
169
175
  timestamp DATETIME(3) NOT NULL
170
176
  )
171
177
  `);
@@ -175,7 +181,8 @@ var init_mysql = __esm({
175
181
  ADD COLUMN IF NOT EXISTS feature VARCHAR(255),
176
182
  ADD COLUMN IF NOT EXISTS cached_tokens INT NOT NULL DEFAULT 0,
177
183
  ADD COLUMN IF NOT EXISTS cache_creation_tokens INT NOT NULL DEFAULT 0,
178
- ADD COLUMN IF NOT EXISTS app_id VARCHAR(255)
184
+ ADD COLUMN IF NOT EXISTS app_id VARCHAR(255),
185
+ ADD COLUMN IF NOT EXISTS metadata JSON
179
186
  `).catch(() => {
180
187
  });
181
188
  }
@@ -183,8 +190,8 @@ var init_mysql = __esm({
183
190
  this.client.execute(
184
191
  `INSERT INTO tokenwatch_usage
185
192
  (model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
186
- cost_usd, session_id, user_id, feature, app_id, timestamp)
187
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
193
+ cost_usd, session_id, user_id, feature, app_id, metadata, timestamp)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
188
195
  [
189
196
  entry.model,
190
197
  entry.inputTokens,
@@ -197,6 +204,7 @@ var init_mysql = __esm({
197
204
  entry.userId ?? null,
198
205
  entry.feature ?? null,
199
206
  entry.appId ?? null,
207
+ entry.metadata != null ? JSON.stringify(entry.metadata) : null,
200
208
  entry.timestamp
201
209
  ]
202
210
  ).catch((err) => {
@@ -240,6 +248,7 @@ function docToEntry(doc) {
240
248
  ...doc.userId != null && { userId: doc.userId },
241
249
  ...doc.feature != null && { feature: doc.feature },
242
250
  ...doc.appId != null && { appId: doc.appId },
251
+ ...doc.metadata != null && { metadata: doc.metadata },
243
252
  timestamp: doc.timestamp
244
253
  };
245
254
  }
@@ -274,6 +283,7 @@ var init_mongodb = __esm({
274
283
  userId: entry.userId ?? null,
275
284
  ...entry.feature !== void 0 && { feature: entry.feature },
276
285
  ...entry.appId !== void 0 && { appId: entry.appId },
286
+ ...entry.metadata !== void 0 && { metadata: entry.metadata },
277
287
  timestamp: entry.timestamp
278
288
  }).catch((err) => {
279
289
  console.warn("[tokenwatch] MongoStorage.record failed:", err);
@@ -404,6 +414,7 @@ var SqliteStorage = class {
404
414
  user_id TEXT,
405
415
  feature TEXT,
406
416
  app_id TEXT,
417
+ metadata TEXT,
407
418
  timestamp TEXT NOT NULL
408
419
  )
409
420
  `);
@@ -423,13 +434,16 @@ var SqliteStorage = class {
423
434
  if (!cols.includes("app_id")) {
424
435
  this.db.exec(`ALTER TABLE usage ADD COLUMN app_id TEXT`);
425
436
  }
437
+ if (!cols.includes("metadata")) {
438
+ this.db.exec(`ALTER TABLE usage ADD COLUMN metadata TEXT`);
439
+ }
426
440
  }
427
441
  record(entry) {
428
442
  this.db.prepare(
429
443
  `INSERT INTO usage
430
444
  (model, input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_creation_tokens,
431
- cost_usd, session_id, user_id, feature, app_id, timestamp)
432
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
445
+ cost_usd, session_id, user_id, feature, app_id, metadata, timestamp)
446
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
433
447
  ).run(
434
448
  entry.model,
435
449
  entry.inputTokens,
@@ -442,6 +456,7 @@ var SqliteStorage = class {
442
456
  entry.userId ?? null,
443
457
  entry.feature ?? null,
444
458
  entry.appId ?? null,
459
+ entry.metadata != null ? JSON.stringify(entry.metadata) : null,
445
460
  entry.timestamp
446
461
  );
447
462
  }
@@ -459,6 +474,7 @@ var SqliteStorage = class {
459
474
  ...r.user_id != null && { userId: r.user_id },
460
475
  ...r.feature != null && { feature: r.feature },
461
476
  ...r.app_id != null && { appId: r.app_id },
477
+ ...r.metadata != null && { metadata: JSON.parse(r.metadata) },
462
478
  timestamp: r.timestamp
463
479
  }));
464
480
  }
@@ -509,6 +525,41 @@ function calculateCost(inputTokens, outputTokens, price, cachedTokens = 0, cache
509
525
  return regularInputCost + cachedReadCost + cacheCreationCost + outputCost;
510
526
  }
511
527
 
528
+ // src/exporters/cloud.ts
529
+ var DEFAULT_ENDPOINT = "https://api.tokenwatch.dev/v1/ingest";
530
+ var CloudExporter = class {
531
+ constructor(apiKey, endpoint) {
532
+ this.apiKey = apiKey;
533
+ this.endpoint = endpoint ?? DEFAULT_ENDPOINT;
534
+ }
535
+ apiKey;
536
+ endpoint;
537
+ export(entry) {
538
+ fetch(this.endpoint, {
539
+ method: "POST",
540
+ headers: {
541
+ "Content-Type": "application/json",
542
+ Authorization: `Bearer ${this.apiKey}`
543
+ },
544
+ body: JSON.stringify({
545
+ model: entry.model,
546
+ inputTokens: entry.inputTokens,
547
+ outputTokens: entry.outputTokens,
548
+ reasoningTokens: entry.reasoningTokens ?? 0,
549
+ cachedTokens: entry.cachedTokens ?? 0,
550
+ cacheCreationTokens: entry.cacheCreationTokens ?? 0,
551
+ costUSD: entry.costUSD,
552
+ sessionId: entry.sessionId,
553
+ userId: entry.userId,
554
+ feature: entry.feature,
555
+ metadata: entry.metadata,
556
+ timestamp: entry.timestamp
557
+ })
558
+ }).catch(() => {
559
+ });
560
+ }
561
+ };
562
+
512
563
  // src/core/suggestions.ts
513
564
  var PROVIDER_PREFIXES = ["gpt-", "claude-", "gemini-", "deepseek-"];
514
565
  function getProviderPrefix(model) {
@@ -1994,7 +2045,9 @@ var TrackerConfigSchema = z.object({
1994
2045
  mode: z.enum(["once", "always"]).optional().default("once")
1995
2046
  }).optional(),
1996
2047
  exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional(),
1997
- appId: z.string().optional()
2048
+ appId: z.string().optional(),
2049
+ cloudApiKey: z.string().optional(),
2050
+ cloudEndpoint: z.string().url().optional()
1998
2051
  });
1999
2052
  function createTracker(config = {}) {
2000
2053
  const parsed = TrackerConfigSchema.safeParse(config);
@@ -2014,9 +2067,12 @@ ${issues}`);
2014
2067
  suggestions,
2015
2068
  anomalyDetection,
2016
2069
  exporter,
2017
- appId
2070
+ appId,
2071
+ cloudApiKey,
2072
+ cloudEndpoint
2018
2073
  } = parsed.data;
2019
2074
  const storage = typeof storageOption === "object" ? storageOption : createStorage(storageOption);
2075
+ const cloudExporter = cloudApiKey ? new CloudExporter(cloudApiKey, cloudEndpoint) : null;
2020
2076
  let remotePrices;
2021
2077
  let pricesUpdatedAt = bundledUpdatedAt;
2022
2078
  if (syncPrices) {
@@ -2077,6 +2133,9 @@ ${issues}`);
2077
2133
  Promise.resolve(exporter.export(full)).catch(() => {
2078
2134
  });
2079
2135
  }
2136
+ if (cloudExporter) {
2137
+ cloudExporter.export(full);
2138
+ }
2080
2139
  maybeFireAlerts(full);
2081
2140
  if (anomalyDetection) maybeDetectAnomaly(full);
2082
2141
  if (suggestions) {
@@ -2158,6 +2217,7 @@ ${issues}`);
2158
2217
  const byUser = {};
2159
2218
  const byFeature = {};
2160
2219
  const byApp = {};
2220
+ const byMetadata = {};
2161
2221
  let totalInput = 0;
2162
2222
  let totalOutput = 0;
2163
2223
  let totalCost = 0;
@@ -2199,6 +2259,14 @@ ${issues}`);
2199
2259
  a.costUSD += e.costUSD;
2200
2260
  a.calls += 1;
2201
2261
  }
2262
+ if (e.metadata) {
2263
+ for (const [key, val] of Object.entries(e.metadata)) {
2264
+ const group = byMetadata[key] ??= {};
2265
+ const slot = group[val] ??= { costUSD: 0, calls: 0 };
2266
+ slot.costUSD += e.costUSD;
2267
+ slot.calls += 1;
2268
+ }
2269
+ }
2202
2270
  }
2203
2271
  if (options && entries.length > 0) {
2204
2272
  periodFrom = entries[0]?.timestamp ?? periodFrom;
@@ -2211,6 +2279,7 @@ ${issues}`);
2211
2279
  byUser,
2212
2280
  byFeature,
2213
2281
  byApp,
2282
+ byMetadata,
2214
2283
  period: { from: periodFrom, to: lastTimestamp },
2215
2284
  ...pricesUpdatedAt ? { pricesUpdatedAt } : {}
2216
2285
  };
@@ -2315,7 +2384,7 @@ ${issues}`);
2315
2384
  }
2316
2385
  async function exportCSV() {
2317
2386
  const entries = await Promise.resolve(storage.getAll());
2318
- const header = "timestamp,model,inputTokens,outputTokens,reasoningTokens,cachedTokens,cacheCreationTokens,costUSD,sessionId,userId,feature,appId";
2387
+ const header = "timestamp,model,inputTokens,outputTokens,reasoningTokens,cachedTokens,cacheCreationTokens,costUSD,sessionId,userId,feature,appId,metadata";
2319
2388
  const rows = entries.map(
2320
2389
  (e) => [
2321
2390
  csvEscape(e.timestamp),
@@ -2329,7 +2398,8 @@ ${issues}`);
2329
2398
  csvEscape(e.sessionId ?? ""),
2330
2399
  csvEscape(e.userId ?? ""),
2331
2400
  csvEscape(e.feature ?? ""),
2332
- csvEscape(e.appId ?? "")
2401
+ csvEscape(e.appId ?? ""),
2402
+ csvEscape(e.metadata ? JSON.stringify(e.metadata) : "")
2333
2403
  ].join(",")
2334
2404
  );
2335
2405
  return [header, ...rows].join("\n");
@@ -2448,6 +2518,7 @@ async function getDashboardData(storage, filter) {
2448
2518
  const byUser = {};
2449
2519
  const byFeature = {};
2450
2520
  const byApp = {};
2521
+ const byMetadata = {};
2451
2522
  let totalInput = 0;
2452
2523
  let totalOutput = 0;
2453
2524
  let totalCost = 0;
@@ -2486,6 +2557,14 @@ async function getDashboardData(storage, filter) {
2486
2557
  a.costUSD += e.costUSD;
2487
2558
  a.calls += 1;
2488
2559
  }
2560
+ if (e.metadata) {
2561
+ for (const [key, val] of Object.entries(e.metadata)) {
2562
+ const group = byMetadata[key] ??= {};
2563
+ const slot = group[val] ??= { costUSD: 0, calls: 0 };
2564
+ slot.costUSD += e.costUSD;
2565
+ slot.calls += 1;
2566
+ }
2567
+ }
2489
2568
  }
2490
2569
  const now = (/* @__PURE__ */ new Date()).toISOString();
2491
2570
  const periodFrom = entries[0]?.timestamp ?? now;
@@ -2498,6 +2577,7 @@ async function getDashboardData(storage, filter) {
2498
2577
  byUser,
2499
2578
  byFeature,
2500
2579
  byApp,
2580
+ byMetadata,
2501
2581
  period: { from: periodFrom, to: periodTo }
2502
2582
  };
2503
2583
  const forecastWindowMs = 24 * 60 * 60 * 1e3;
@@ -3473,7 +3553,65 @@ function ModelTable({ models, total, t, onRowClick, exampleHover }) {
3473
3553
  </section>
3474
3554
  );
3475
3555
  }
3476
- Object.assign(window, { ModelTable });
3556
+ function MetaGroup({ label, rows, t }) {
3557
+ const [open, setOpen] = useStateT(true);
3558
+ const { fmtUSD, fmtInt } = window.TW;
3559
+ const total = rows.reduce(function(s, r) { return s + r[1].costUSD; }, 0);
3560
+ return (
3561
+ <section className="tw-section">
3562
+ <div className="tw-sec-head" style={{ cursor: 'pointer' }} onClick={() => setOpen(function(v) { return !v; })}>
3563
+ <h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
3564
+ <button className="tw-collapse" style={{ transform: open ? 'none' : 'rotate(-90deg)' }}><Ico.chevron /></button>
3565
+ {label}
3566
+ </h3>
3567
+ <span className="tw-sec-sub">{rows.length + ' values \xB7 ' + fmtUSD(total, 4)}</span>
3568
+ </div>
3569
+ {open && (
3570
+ <div className="tw-table-wrap">
3571
+ <table className={'tw-table' + (t.density === 'compact' ? ' compact' : '')}>
3572
+ <thead>
3573
+ <tr>
3574
+ <th style={{ textAlign: 'left' }}>Value</th>
3575
+ <th style={{ textAlign: 'right' }}>Cost</th>
3576
+ <th style={{ textAlign: 'right' }}>Calls</th>
3577
+ <th style={{ textAlign: 'right' }}>Avg / call</th>
3578
+ </tr>
3579
+ </thead>
3580
+ <tbody>
3581
+ {rows.map(function(r) {
3582
+ var val = r[0], stats = r[1];
3583
+ return (
3584
+ <tr key={val} className="tw-row">
3585
+ <td>{val}</td>
3586
+ <td style={{ textAlign: 'right' }}>{fmtUSD(stats.costUSD, 4)}</td>
3587
+ <td style={{ textAlign: 'right' }}>{fmtInt(stats.calls)}</td>
3588
+ <td style={{ textAlign: 'right' }}>{fmtUSD(stats.costUSD / Math.max(stats.calls, 1), 4)}</td>
3589
+ </tr>
3590
+ );
3591
+ })}
3592
+ </tbody>
3593
+ </table>
3594
+ </div>
3595
+ )}
3596
+ </section>
3597
+ );
3598
+ }
3599
+
3600
+ function MetadataSection({ byMetadata, t }) {
3601
+ var keys = Object.keys(byMetadata || {});
3602
+ if (keys.length === 0) return null;
3603
+ return (
3604
+ <div>
3605
+ {keys.map(function(key) {
3606
+ var group = byMetadata[key];
3607
+ var rows = Object.entries(group).sort(function(a, b) { return b[1].costUSD - a[1].costUSD; });
3608
+ return <MetaGroup key={key} label={key} rows={rows} t={t} />;
3609
+ })}
3610
+ </div>
3611
+ );
3612
+ }
3613
+
3614
+ Object.assign(window, { ModelTable, MetaGroup, MetadataSection });
3477
3615
  </script>
3478
3616
 
3479
3617
  <script type="text/babel">
@@ -3782,6 +3920,7 @@ Object.assign(window, {
3782
3920
  BASE_MODELS, RANGES, modelsForRange, kpisForRange,
3783
3921
  seriesForRange, seedFeed, makeCall, callsForModel,
3784
3922
  FEATURES, FEATURE_COLOR, BUDGET, _buildSeries: buildSeries,
3923
+ byMetadata: {},
3785
3924
  },
3786
3925
  });
3787
3926
 
@@ -3826,6 +3965,7 @@ Object.assign(window, {
3826
3965
  var elapsed = window.TW.BUDGET.cycleDays - window.TW.BUDGET.daysLeft;
3827
3966
  window.TW.BUDGET.used = fc.burnRatePerHour * 24 * Math.max(elapsed, 1);
3828
3967
  }
3968
+ if (r.byMetadata) window.TW.byMetadata = r.byMetadata;
3829
3969
  window.dispatchEvent(new CustomEvent('tw-data-update'));
3830
3970
  }
3831
3971
  var evtSource = null;
@@ -3968,6 +4108,10 @@ function App() {
3968
4108
 
3969
4109
  const models = kpis.models;
3970
4110
 
4111
+ const byMetadata = useMemoApp(() => {
4112
+ return window.TW.byMetadata || {};
4113
+ }, [_sseV]);
4114
+
3971
4115
  useEffectApp(() => {
3972
4116
  const onKey = (e) => {
3973
4117
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
@@ -4003,6 +4147,7 @@ function App() {
4003
4147
  <ChartsRow range={range} models={models} kpis={kpis} series={series} t={t} />
4004
4148
  <ModelTable models={models} total={kpis.cost} t={t} exampleHover={t.smartHighlight}
4005
4149
  onRowClick={(m) => setSelModel({ ...m, share: m.cost / Math.max(kpis.cost, 0.0001) })} />
4150
+ <MetadataSection byMetadata={byMetadata} t={t} />
4006
4151
  <ForecastSection t={t} />
4007
4152
  <LiveActivity t={t} />
4008
4153
  </main>