@diogonzafe/tokenwatch 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -245,7 +245,7 @@ function maybeSuggestCheaperModel(model, costUSD, inputTokens, outputTokens, lay
245
245
 
246
246
  // prices.json
247
247
  var prices_default = {
248
- updated_at: "2026-04-22",
248
+ updated_at: "2026-04-23",
249
249
  source: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
250
250
  models: {
251
251
  "gpt-4o": {
@@ -1594,7 +1594,14 @@ var TrackerConfigSchema = z.object({
1594
1594
  perUser: BudgetConfigSchema.optional(),
1595
1595
  perSession: BudgetConfigSchema.optional()
1596
1596
  }).optional(),
1597
- suggestions: z.boolean().optional().default(false)
1597
+ suggestions: z.boolean().optional().default(false),
1598
+ anomalyDetection: z.object({
1599
+ multiplierThreshold: z.number().positive(),
1600
+ webhookUrl: z.string().url(),
1601
+ windowHours: z.number().positive().optional().default(24),
1602
+ mode: z.enum(["once", "always"]).optional().default("once")
1603
+ }).optional(),
1604
+ exporter: z.custom((v) => v !== null && typeof v === "object" && typeof v.export === "function").optional()
1598
1605
  });
1599
1606
  function createTracker(config = {}) {
1600
1607
  const parsed = TrackerConfigSchema.safeParse(config);
@@ -1611,7 +1618,9 @@ ${issues}`);
1611
1618
  customPrices,
1612
1619
  warnIfStaleAfterHours,
1613
1620
  budgets,
1614
- suggestions
1621
+ suggestions,
1622
+ anomalyDetection,
1623
+ exporter
1615
1624
  } = parsed.data;
1616
1625
  const storage = typeof storageOption === "object" ? storageOption : createStorage(storageOption);
1617
1626
  let remotePrices;
@@ -1644,6 +1653,7 @@ ${issues}`);
1644
1653
  let alertFired = false;
1645
1654
  const firedUserAlerts = /* @__PURE__ */ new Set();
1646
1655
  const firedSessionAlerts = /* @__PURE__ */ new Set();
1656
+ const firedAnomalyKeys = /* @__PURE__ */ new Set();
1647
1657
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1648
1658
  function resolveModelPrice(model) {
1649
1659
  maybeWarnStaleness();
@@ -1668,7 +1678,12 @@ ${issues}`);
1668
1678
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1669
1679
  };
1670
1680
  storage.record(full);
1681
+ if (exporter) {
1682
+ Promise.resolve(exporter.export(full)).catch(() => {
1683
+ });
1684
+ }
1671
1685
  maybeFireAlerts(full);
1686
+ if (anomalyDetection) maybeDetectAnomaly(full);
1672
1687
  if (suggestions) {
1673
1688
  maybeSuggestCheaperModel(entry.model, costUSD, entry.inputTokens, entry.outputTokens, {
1674
1689
  bundledPrices,
@@ -1838,11 +1853,56 @@ ${issues}`);
1838
1853
  basedOnPeriod: { from: first, to: last }
1839
1854
  };
1840
1855
  }
1856
+ function maybeDetectAnomaly(entry) {
1857
+ if (entry.costUSD <= 0) return;
1858
+ const { multiplierThreshold, webhookUrl: aUrl, windowHours: wh, mode: modeRaw } = anomalyDetection;
1859
+ const wHours = wh ?? 24;
1860
+ const mode = modeRaw ?? "once";
1861
+ const windowStart = Date.now() - wHours * 60 * 60 * 1e3;
1862
+ const entryTs = new Date(entry.timestamp).getTime();
1863
+ function checkEntity(key, label, predicate) {
1864
+ if (mode !== "always" && firedAnomalyKeys.has(key)) return;
1865
+ if (mode !== "always") firedAnomalyKeys.add(key);
1866
+ Promise.resolve(storage.getAll()).then((all) => {
1867
+ const history = all.filter(
1868
+ (e) => predicate(e) && new Date(e.timestamp).getTime() >= windowStart && new Date(e.timestamp).getTime() !== entryTs
1869
+ );
1870
+ if (history.length === 0) {
1871
+ if (mode !== "always") firedAnomalyKeys.delete(key);
1872
+ return;
1873
+ }
1874
+ const avg = history.reduce((s, e) => s + e.costUSD, 0) / history.length;
1875
+ if (avg <= 0 || entry.costUSD <= avg * multiplierThreshold) {
1876
+ if (mode !== "always") firedAnomalyKeys.delete(key);
1877
+ return;
1878
+ }
1879
+ const multiple = (entry.costUSD / avg).toFixed(1);
1880
+ fireWebhook(aUrl, {
1881
+ text: `[tokenwatch] Anomaly: ${label} call cost $${entry.costUSD.toFixed(4)} is ${multiple}x above ${wHours}h average ($${avg.toFixed(4)})`
1882
+ });
1883
+ }).catch(() => {
1884
+ if (mode !== "always") firedAnomalyKeys.delete(key);
1885
+ });
1886
+ }
1887
+ if (entry.userId) {
1888
+ checkEntity(
1889
+ `user:${entry.userId}`,
1890
+ `user "${entry.userId}"`,
1891
+ (e) => e.userId === entry.userId
1892
+ );
1893
+ }
1894
+ checkEntity(
1895
+ `model:${entry.model}`,
1896
+ `model "${entry.model}"`,
1897
+ (e) => e.model === entry.model
1898
+ );
1899
+ }
1841
1900
  async function reset() {
1842
1901
  await Promise.resolve(storage.clearAll());
1843
1902
  alertFired = false;
1844
1903
  firedUserAlerts.clear();
1845
1904
  firedSessionAlerts.clear();
1905
+ firedAnomalyKeys.clear();
1846
1906
  }
1847
1907
  async function resetSession(sessionId) {
1848
1908
  await Promise.resolve(storage.clearSession(sessionId));
@@ -1926,6 +1986,764 @@ function csvEscape(value) {
1926
1986
  return value;
1927
1987
  }
1928
1988
 
1989
+ // src/dashboard/server.ts
1990
+ import { createServer } from "http";
1991
+
1992
+ // src/dashboard/data.ts
1993
+ function parseSince(filter) {
1994
+ if (!filter || filter === "all") return void 0;
1995
+ const now = Date.now();
1996
+ switch (filter) {
1997
+ case "1h":
1998
+ return now - 60 * 60 * 1e3;
1999
+ case "24h":
2000
+ return now - 24 * 60 * 60 * 1e3;
2001
+ case "7d":
2002
+ return now - 7 * 24 * 60 * 60 * 1e3;
2003
+ case "30d":
2004
+ return now - 30 * 24 * 60 * 60 * 1e3;
2005
+ default:
2006
+ return void 0;
2007
+ }
2008
+ }
2009
+ function buildTimeSeries(entries, sinceMs) {
2010
+ const now = Date.now();
2011
+ const windowMs = sinceMs !== void 0 ? now - sinceMs : void 0;
2012
+ let bucketMs;
2013
+ if (windowMs !== void 0 && windowMs <= 60 * 60 * 1e3) {
2014
+ bucketMs = 5 * 60 * 1e3;
2015
+ } else if (windowMs !== void 0 && windowMs <= 24 * 60 * 60 * 1e3) {
2016
+ bucketMs = 60 * 60 * 1e3;
2017
+ } else {
2018
+ bucketMs = 24 * 60 * 60 * 1e3;
2019
+ }
2020
+ const filtered = sinceMs !== void 0 ? entries.filter((e) => new Date(e.timestamp).getTime() >= sinceMs) : entries;
2021
+ const buckets = /* @__PURE__ */ new Map();
2022
+ for (const entry of filtered) {
2023
+ const ts = new Date(entry.timestamp).getTime();
2024
+ const bucketTs = Math.floor(ts / bucketMs) * bucketMs;
2025
+ const bucketKey = new Date(bucketTs).toISOString();
2026
+ const existing = buckets.get(bucketKey);
2027
+ if (existing) {
2028
+ existing.cost += entry.costUSD;
2029
+ existing.calls += 1;
2030
+ } else {
2031
+ buckets.set(bucketKey, { bucket: bucketKey, cost: entry.costUSD, calls: 1 });
2032
+ }
2033
+ }
2034
+ return Array.from(buckets.values()).sort((a, b) => a.bucket.localeCompare(b.bucket));
2035
+ }
2036
+ function getFingerprint(data) {
2037
+ return `${data.report.totalCostUSD.toFixed(8)}-${data.report.totalTokens.input}-${data.timeSeries.length}`;
2038
+ }
2039
+ async function getDashboardData(storage, filter) {
2040
+ const allEntries = await Promise.resolve(storage.getAll());
2041
+ const sinceMs = parseSince(filter);
2042
+ const entries = sinceMs !== void 0 ? allEntries.filter((e) => new Date(e.timestamp).getTime() >= sinceMs) : allEntries;
2043
+ const byModel = {};
2044
+ const bySession = {};
2045
+ const byUser = {};
2046
+ const byFeature = {};
2047
+ let totalInput = 0;
2048
+ let totalOutput = 0;
2049
+ let totalCost = 0;
2050
+ for (const e of entries) {
2051
+ totalInput += e.inputTokens + (e.cachedTokens ?? 0) + (e.cacheCreationTokens ?? 0);
2052
+ totalOutput += e.outputTokens;
2053
+ totalCost += e.costUSD;
2054
+ const m = byModel[e.model] ??= {
2055
+ costUSD: 0,
2056
+ calls: 0,
2057
+ tokens: { input: 0, output: 0, reasoning: 0, cached: 0 }
2058
+ };
2059
+ m.costUSD += e.costUSD;
2060
+ m.calls += 1;
2061
+ m.tokens.input += e.inputTokens + (e.cachedTokens ?? 0) + (e.cacheCreationTokens ?? 0);
2062
+ m.tokens.output += e.outputTokens;
2063
+ m.tokens.reasoning += e.reasoningTokens ?? 0;
2064
+ m.tokens.cached += e.cachedTokens ?? 0;
2065
+ if (e.sessionId) {
2066
+ const s = bySession[e.sessionId] ??= { costUSD: 0, calls: 0 };
2067
+ s.costUSD += e.costUSD;
2068
+ s.calls += 1;
2069
+ }
2070
+ if (e.userId) {
2071
+ const u = byUser[e.userId] ??= { costUSD: 0, calls: 0 };
2072
+ u.costUSD += e.costUSD;
2073
+ u.calls += 1;
2074
+ }
2075
+ if (e.feature) {
2076
+ const f = byFeature[e.feature] ??= { costUSD: 0, calls: 0 };
2077
+ f.costUSD += e.costUSD;
2078
+ f.calls += 1;
2079
+ }
2080
+ }
2081
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2082
+ const periodFrom = entries[0]?.timestamp ?? now;
2083
+ const periodTo = entries[entries.length - 1]?.timestamp ?? now;
2084
+ const report = {
2085
+ totalCostUSD: totalCost,
2086
+ totalTokens: { input: totalInput, output: totalOutput },
2087
+ byModel,
2088
+ bySession,
2089
+ byUser,
2090
+ byFeature,
2091
+ period: { from: periodFrom, to: periodTo }
2092
+ };
2093
+ const forecastWindowMs = 24 * 60 * 60 * 1e3;
2094
+ const windowStart = Date.now() - forecastWindowMs;
2095
+ const windowEntries = allEntries.filter(
2096
+ (e) => new Date(e.timestamp).getTime() >= windowStart
2097
+ );
2098
+ let forecast;
2099
+ if (windowEntries.length < 2) {
2100
+ forecast = {
2101
+ burnRatePerHour: 0,
2102
+ projectedDailyCostUSD: 0,
2103
+ projectedMonthlyCostUSD: 0,
2104
+ basedOnHours: 0,
2105
+ basedOnPeriod: null
2106
+ };
2107
+ } else {
2108
+ const first = windowEntries[0]?.timestamp ?? "";
2109
+ const last = windowEntries[windowEntries.length - 1]?.timestamp ?? "";
2110
+ const actualMs = new Date(last).getTime() - new Date(first).getTime();
2111
+ const actualHours = actualMs / (1e3 * 60 * 60);
2112
+ if (actualHours < 1e-3) {
2113
+ forecast = {
2114
+ burnRatePerHour: 0,
2115
+ projectedDailyCostUSD: 0,
2116
+ projectedMonthlyCostUSD: 0,
2117
+ basedOnHours: 0,
2118
+ basedOnPeriod: { from: first, to: last }
2119
+ };
2120
+ } else {
2121
+ const windowCost = windowEntries.reduce((s, e) => s + e.costUSD, 0);
2122
+ const burnRatePerHour = windowCost / actualHours;
2123
+ forecast = {
2124
+ burnRatePerHour,
2125
+ projectedDailyCostUSD: burnRatePerHour * 24,
2126
+ projectedMonthlyCostUSD: burnRatePerHour * 24 * 30,
2127
+ basedOnHours: Math.round(actualHours * 100) / 100,
2128
+ basedOnPeriod: { from: first, to: last }
2129
+ };
2130
+ }
2131
+ }
2132
+ const timeSeries = buildTimeSeries(allEntries, sinceMs);
2133
+ return { report, forecast, timeSeries, lastUpdated: now };
2134
+ }
2135
+
2136
+ // src/dashboard/html.ts
2137
+ function getHtml(port) {
2138
+ void port;
2139
+ return `<!DOCTYPE html>
2140
+ <html lang="en">
2141
+ <head>
2142
+ <meta charset="UTF-8" />
2143
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2144
+ <title>tokenwatch dashboard</title>
2145
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
2146
+ <style>
2147
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2148
+
2149
+ :root {
2150
+ --bg: #0d1117;
2151
+ --surface: #161b22;
2152
+ --border: #30363d;
2153
+ --text: #e6edf3;
2154
+ --muted: #8b949e;
2155
+ --accent: #58a6ff;
2156
+ --green: #3fb950;
2157
+ --yellow: #d29922;
2158
+ --red: #f85149;
2159
+ --r: 6px;
2160
+ }
2161
+
2162
+ body {
2163
+ background: var(--bg);
2164
+ color: var(--text);
2165
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2166
+ font-size: 14px;
2167
+ line-height: 1.5;
2168
+ min-height: 100vh;
2169
+ }
2170
+
2171
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
2172
+
2173
+ /* \u2500\u2500 Header \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\u2500 */
2174
+ header {
2175
+ display: flex;
2176
+ align-items: center;
2177
+ justify-content: space-between;
2178
+ margin-bottom: 24px;
2179
+ }
2180
+ header h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
2181
+ header h1 span { color: var(--accent); }
2182
+
2183
+ .live-badge {
2184
+ display: flex;
2185
+ align-items: center;
2186
+ gap: 6px;
2187
+ font-size: 12px;
2188
+ color: var(--muted);
2189
+ }
2190
+ .live-dot {
2191
+ width: 8px; height: 8px;
2192
+ border-radius: 50%;
2193
+ background: var(--green);
2194
+ animation: pulse 2s ease-in-out infinite;
2195
+ }
2196
+ @keyframes pulse {
2197
+ 0%, 100% { opacity: 1; }
2198
+ 50% { opacity: 0.3; }
2199
+ }
2200
+
2201
+ /* \u2500\u2500 Tabs \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\u2500\u2500\u2500\u2500 */
2202
+ .tabs {
2203
+ display: flex;
2204
+ gap: 4px;
2205
+ margin-bottom: 24px;
2206
+ background: var(--surface);
2207
+ border: 1px solid var(--border);
2208
+ border-radius: var(--r);
2209
+ padding: 4px;
2210
+ width: fit-content;
2211
+ }
2212
+ .tab {
2213
+ padding: 6px 14px;
2214
+ border-radius: calc(var(--r) - 2px);
2215
+ border: none;
2216
+ background: transparent;
2217
+ color: var(--muted);
2218
+ font-size: 13px;
2219
+ font-weight: 500;
2220
+ cursor: pointer;
2221
+ transition: background 0.15s, color 0.15s;
2222
+ }
2223
+ .tab:hover { color: var(--text); background: rgba(255,255,255,0.05); }
2224
+ .tab.active { background: var(--accent); color: #fff; }
2225
+
2226
+ /* \u2500\u2500 Overview cards \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 */
2227
+ .cards {
2228
+ display: grid;
2229
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2230
+ gap: 12px;
2231
+ margin-bottom: 24px;
2232
+ }
2233
+ .card {
2234
+ background: var(--surface);
2235
+ border: 1px solid var(--border);
2236
+ border-radius: var(--r);
2237
+ padding: 16px;
2238
+ }
2239
+ .card-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 6px; }
2240
+ .card-value { font-size: 22px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
2241
+ .card-sub { font-size: 11px; color: var(--muted); margin-top: 3px; }
2242
+
2243
+ /* \u2500\u2500 Chart section \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 */
2244
+ .charts {
2245
+ display: grid;
2246
+ grid-template-columns: 2fr 1fr;
2247
+ gap: 16px;
2248
+ margin-bottom: 24px;
2249
+ }
2250
+ @media (max-width: 768px) { .charts { grid-template-columns: 1fr; } }
2251
+
2252
+ .panel {
2253
+ background: var(--surface);
2254
+ border: 1px solid var(--border);
2255
+ border-radius: var(--r);
2256
+ padding: 16px;
2257
+ }
2258
+ .panel-title {
2259
+ font-size: 12px;
2260
+ font-weight: 600;
2261
+ text-transform: uppercase;
2262
+ letter-spacing: 0.5px;
2263
+ color: var(--muted);
2264
+ margin-bottom: 14px;
2265
+ }
2266
+ .chart-wrap { position: relative; height: 220px; }
2267
+
2268
+ /* \u2500\u2500 Breakdown tables \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 */
2269
+ .section { margin-bottom: 24px; }
2270
+ .section-title {
2271
+ font-size: 12px;
2272
+ font-weight: 600;
2273
+ text-transform: uppercase;
2274
+ letter-spacing: 0.5px;
2275
+ color: var(--muted);
2276
+ margin-bottom: 10px;
2277
+ }
2278
+
2279
+ table {
2280
+ width: 100%;
2281
+ border-collapse: collapse;
2282
+ background: var(--surface);
2283
+ border: 1px solid var(--border);
2284
+ border-radius: var(--r);
2285
+ overflow: hidden;
2286
+ font-size: 13px;
2287
+ }
2288
+ thead { background: rgba(255,255,255,0.03); }
2289
+ th {
2290
+ padding: 10px 14px;
2291
+ text-align: left;
2292
+ font-weight: 600;
2293
+ color: var(--muted);
2294
+ font-size: 11px;
2295
+ text-transform: uppercase;
2296
+ letter-spacing: 0.4px;
2297
+ border-bottom: 1px solid var(--border);
2298
+ }
2299
+ th.num, td.num { text-align: right; }
2300
+ td {
2301
+ padding: 10px 14px;
2302
+ border-bottom: 1px solid rgba(48,54,61,0.5);
2303
+ font-variant-numeric: tabular-nums;
2304
+ color: var(--text);
2305
+ }
2306
+ tbody tr:last-child td { border-bottom: none; }
2307
+ tbody tr:hover td { background: rgba(255,255,255,0.02); }
2308
+
2309
+ .bar-wrap { width: 80px; height: 6px; background: var(--border); border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 8px; }
2310
+ .bar-fill { height: 100%; border-radius: 3px; background: var(--accent); }
2311
+
2312
+ /* \u2500\u2500 Forecast \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 */
2313
+ .forecast-grid {
2314
+ display: grid;
2315
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
2316
+ gap: 12px;
2317
+ }
2318
+
2319
+ /* \u2500\u2500 Empty state \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 */
2320
+ .empty {
2321
+ color: var(--muted);
2322
+ font-size: 13px;
2323
+ padding: 20px 0;
2324
+ text-align: center;
2325
+ }
2326
+
2327
+ /* \u2500\u2500 Collapsible \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 */
2328
+ details summary {
2329
+ cursor: pointer;
2330
+ list-style: none;
2331
+ display: flex;
2332
+ align-items: center;
2333
+ gap: 6px;
2334
+ font-size: 12px;
2335
+ font-weight: 600;
2336
+ text-transform: uppercase;
2337
+ letter-spacing: 0.5px;
2338
+ color: var(--muted);
2339
+ margin-bottom: 10px;
2340
+ user-select: none;
2341
+ }
2342
+ details summary::before { content: '\u25B6'; font-size: 10px; transition: transform 0.15s; }
2343
+ details[open] summary::before { transform: rotate(90deg); }
2344
+
2345
+ /* \u2500\u2500 Last updated \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 */
2346
+ .footer {
2347
+ font-size: 11px;
2348
+ color: var(--muted);
2349
+ text-align: center;
2350
+ padding: 16px 0 0;
2351
+ }
2352
+ </style>
2353
+ </head>
2354
+ <body>
2355
+ <div class="container">
2356
+
2357
+ <header>
2358
+ <h1>token<span>watch</span></h1>
2359
+ <div class="live-badge">
2360
+ <div class="live-dot"></div>
2361
+ <span id="live-label">live</span>
2362
+ </div>
2363
+ </header>
2364
+
2365
+ <div class="tabs">
2366
+ <button class="tab" data-filter="1h">1h</button>
2367
+ <button class="tab active" data-filter="24h">24h</button>
2368
+ <button class="tab" data-filter="7d">7d</button>
2369
+ <button class="tab" data-filter="30d">30d</button>
2370
+ <button class="tab" data-filter="all">All</button>
2371
+ </div>
2372
+
2373
+ <div class="cards">
2374
+ <div class="card">
2375
+ <div class="card-label">Total Cost</div>
2376
+ <div class="card-value" id="card-cost">\u2014</div>
2377
+ <div class="card-sub" id="card-period"></div>
2378
+ </div>
2379
+ <div class="card">
2380
+ <div class="card-label">Input Tokens</div>
2381
+ <div class="card-value" id="card-input">\u2014</div>
2382
+ </div>
2383
+ <div class="card">
2384
+ <div class="card-label">Output Tokens</div>
2385
+ <div class="card-value" id="card-output">\u2014</div>
2386
+ </div>
2387
+ <div class="card">
2388
+ <div class="card-label">Total Calls</div>
2389
+ <div class="card-value" id="card-calls">\u2014</div>
2390
+ </div>
2391
+ <div class="card">
2392
+ <div class="card-label">Burn Rate</div>
2393
+ <div class="card-value" id="card-burn">\u2014</div>
2394
+ <div class="card-sub">per hour</div>
2395
+ </div>
2396
+ </div>
2397
+
2398
+ <div class="charts">
2399
+ <div class="panel">
2400
+ <div class="panel-title">Cost over time</div>
2401
+ <div class="chart-wrap">
2402
+ <canvas id="chart-line"></canvas>
2403
+ </div>
2404
+ </div>
2405
+ <div class="panel">
2406
+ <div class="panel-title">By model</div>
2407
+ <div class="chart-wrap">
2408
+ <canvas id="chart-doughnut"></canvas>
2409
+ </div>
2410
+ </div>
2411
+ </div>
2412
+
2413
+ <div class="section">
2414
+ <div class="section-title">Model breakdown</div>
2415
+ <div id="model-table-wrap"></div>
2416
+ </div>
2417
+
2418
+ <details id="users-section" style="margin-bottom:24px;">
2419
+ <summary>By user</summary>
2420
+ <div id="user-table-wrap"></div>
2421
+ </details>
2422
+
2423
+ <details id="features-section" style="margin-bottom:24px;">
2424
+ <summary>By feature</summary>
2425
+ <div id="feature-table-wrap"></div>
2426
+ </details>
2427
+
2428
+ <div class="section">
2429
+ <div class="section-title">Cost forecast</div>
2430
+ <div class="forecast-grid">
2431
+ <div class="card">
2432
+ <div class="card-label">Projected daily</div>
2433
+ <div class="card-value" id="fc-daily">\u2014</div>
2434
+ <div class="card-sub" id="fc-window"></div>
2435
+ </div>
2436
+ <div class="card">
2437
+ <div class="card-label">Projected monthly</div>
2438
+ <div class="card-value" id="fc-monthly">\u2014</div>
2439
+ </div>
2440
+ <div class="card">
2441
+ <div class="card-label">Burn rate / hr</div>
2442
+ <div class="card-value" id="fc-burn">\u2014</div>
2443
+ </div>
2444
+ </div>
2445
+ </div>
2446
+
2447
+ <div class="footer" id="footer-updated"></div>
2448
+
2449
+ </div><!-- /container -->
2450
+
2451
+ <script>
2452
+ (function () {
2453
+ 'use strict';
2454
+
2455
+ // \u2500\u2500 Palette \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2456
+ const PALETTE = [
2457
+ '#58a6ff','#3fb950','#f78166','#d29922','#bc8cff',
2458
+ '#79c0ff','#56d364','#ffa657','#ff7b72','#a5d6ff',
2459
+ ];
2460
+
2461
+ // \u2500\u2500 State \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2462
+ let evtSource = null;
2463
+ let activeFilter = '24h';
2464
+ let lineChart = null;
2465
+ let doughnutChart = null;
2466
+
2467
+ // \u2500\u2500 Helpers \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2468
+ function escHtml(str) {
2469
+ return String(str)
2470
+ .replace(/&/g, '&amp;')
2471
+ .replace(/</g, '&lt;')
2472
+ .replace(/>/g, '&gt;')
2473
+ .replace(/"/g, '&quot;');
2474
+ }
2475
+
2476
+ function fmtUSD(n) {
2477
+ if (n === 0) return '$0.00';
2478
+ if (n < 0.001) return '$' + n.toFixed(6);
2479
+ if (n < 1) return '$' + n.toFixed(4);
2480
+ return '$' + n.toFixed(2);
2481
+ }
2482
+
2483
+ function fmtNum(n) {
2484
+ return n.toLocaleString('en-US');
2485
+ }
2486
+
2487
+ function fmtDate(iso) {
2488
+ try { return new Date(iso).toLocaleString(); } catch { return iso; }
2489
+ }
2490
+
2491
+ function totalCalls(byModel) {
2492
+ return Object.values(byModel).reduce(function(s, m) { return s + m.calls; }, 0);
2493
+ }
2494
+
2495
+ // \u2500\u2500 Tab setup \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\u2500\u2500\u2500\u2500\u2500\u2500
2496
+ document.querySelectorAll('.tab').forEach(function(btn) {
2497
+ btn.addEventListener('click', function() {
2498
+ document.querySelectorAll('.tab').forEach(function(b) { b.classList.remove('active'); });
2499
+ btn.classList.add('active');
2500
+ activeFilter = btn.dataset.filter;
2501
+ reconnect();
2502
+ });
2503
+ });
2504
+
2505
+ // \u2500\u2500 SSE \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2506
+ function reconnect() {
2507
+ if (evtSource) { evtSource.close(); evtSource = null; }
2508
+ evtSource = new EventSource('/events?filter=' + encodeURIComponent(activeFilter));
2509
+ evtSource.onmessage = function(e) {
2510
+ try { updateUI(JSON.parse(e.data)); } catch (_) {}
2511
+ };
2512
+ evtSource.onerror = function() {
2513
+ document.getElementById('live-label').textContent = 'reconnecting\u2026';
2514
+ };
2515
+ evtSource.onopen = function() {
2516
+ document.getElementById('live-label').textContent = 'live';
2517
+ };
2518
+ }
2519
+
2520
+ // \u2500\u2500 UI update \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\u2500\u2500\u2500\u2500\u2500\u2500
2521
+ function updateUI(data) {
2522
+ const r = data.report;
2523
+ const fc = data.forecast;
2524
+ const ts = data.timeSeries;
2525
+
2526
+ // Cards
2527
+ document.getElementById('card-cost').textContent = fmtUSD(r.totalCostUSD);
2528
+ document.getElementById('card-input').textContent = fmtNum(r.totalTokens.input);
2529
+ document.getElementById('card-output').textContent = fmtNum(r.totalTokens.output);
2530
+ document.getElementById('card-calls').textContent = fmtNum(totalCalls(r.byModel));
2531
+ document.getElementById('card-burn').textContent = fmtUSD(fc.burnRatePerHour);
2532
+ document.getElementById('card-period').textContent =
2533
+ r.period.from !== r.period.to
2534
+ ? fmtDate(r.period.from) + ' \u2013 ' + fmtDate(r.period.to)
2535
+ : '';
2536
+
2537
+ // Line chart
2538
+ const labels = ts.map(function(b) {
2539
+ try { return new Date(b.bucket).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
2540
+ catch { return b.bucket; }
2541
+ });
2542
+ const costs = ts.map(function(b) { return b.cost; });
2543
+
2544
+ if (!lineChart) {
2545
+ const ctx = document.getElementById('chart-line').getContext('2d');
2546
+ lineChart = new Chart(ctx, {
2547
+ type: 'line',
2548
+ data: {
2549
+ labels: labels,
2550
+ datasets: [{
2551
+ label: 'Cost (USD)',
2552
+ data: costs,
2553
+ borderColor: '#58a6ff',
2554
+ backgroundColor: 'rgba(88,166,255,0.08)',
2555
+ borderWidth: 2,
2556
+ pointRadius: 3,
2557
+ pointBackgroundColor: '#58a6ff',
2558
+ fill: true,
2559
+ tension: 0.3,
2560
+ }],
2561
+ },
2562
+ options: {
2563
+ responsive: true, maintainAspectRatio: false,
2564
+ plugins: { legend: { display: false } },
2565
+ scales: {
2566
+ x: { ticks: { color: '#8b949e', maxTicksLimit: 8 }, grid: { color: '#21262d' } },
2567
+ y: {
2568
+ ticks: {
2569
+ color: '#8b949e',
2570
+ callback: function(v) { return '$' + Number(v).toFixed(4); },
2571
+ },
2572
+ grid: { color: '#21262d' },
2573
+ },
2574
+ },
2575
+ },
2576
+ });
2577
+ } else {
2578
+ lineChart.data.labels = labels;
2579
+ lineChart.data.datasets[0].data = costs;
2580
+ lineChart.update('none');
2581
+ }
2582
+
2583
+ // Doughnut chart
2584
+ const modelEntries = Object.entries(r.byModel);
2585
+ const dLabels = modelEntries.map(function(x) { return x[0]; });
2586
+ const dData = modelEntries.map(function(x) { return x[1].costUSD; });
2587
+ const dColors = dLabels.map(function(_, i) { return PALETTE[i % PALETTE.length]; });
2588
+
2589
+ if (!doughnutChart) {
2590
+ const ctx2 = document.getElementById('chart-doughnut').getContext('2d');
2591
+ doughnutChart = new Chart(ctx2, {
2592
+ type: 'doughnut',
2593
+ data: { labels: dLabels, datasets: [{ data: dData, backgroundColor: dColors, borderWidth: 0, hoverOffset: 4 }] },
2594
+ options: {
2595
+ responsive: true, maintainAspectRatio: false,
2596
+ cutout: '65%',
2597
+ plugins: {
2598
+ legend: {
2599
+ position: 'bottom',
2600
+ labels: { color: '#8b949e', boxWidth: 10, padding: 12, font: { size: 11 } },
2601
+ },
2602
+ },
2603
+ },
2604
+ });
2605
+ } else {
2606
+ doughnutChart.data.labels = dLabels;
2607
+ doughnutChart.data.datasets[0].data = dData;
2608
+ doughnutChart.data.datasets[0].backgroundColor = dColors;
2609
+ doughnutChart.update('none');
2610
+ }
2611
+
2612
+ // Model table
2613
+ const modelWrap = document.getElementById('model-table-wrap');
2614
+ if (modelEntries.length === 0) {
2615
+ modelWrap.innerHTML = '<p class="empty">No data for this period.</p>';
2616
+ } else {
2617
+ const totalCost = r.totalCostUSD || 1;
2618
+ let html = '<table><thead><tr>' +
2619
+ '<th>Model</th>' +
2620
+ '<th class="num">Calls</th>' +
2621
+ '<th class="num">In tokens</th>' +
2622
+ '<th class="num">Out tokens</th>' +
2623
+ '<th class="num">Cost</th>' +
2624
+ '<th class="num">Share</th>' +
2625
+ '</tr></thead><tbody>';
2626
+ const sorted = modelEntries.slice().sort(function(a, b) { return b[1].costUSD - a[1].costUSD; });
2627
+ sorted.forEach(function(entry) {
2628
+ const name = entry[0]; const m = entry[1];
2629
+ const pct = (m.costUSD / totalCost * 100).toFixed(1);
2630
+ const barW = Math.round(m.costUSD / totalCost * 80);
2631
+ html += '<tr>' +
2632
+ '<td>' + escHtml(name) + '</td>' +
2633
+ '<td class="num">' + fmtNum(m.calls) + '</td>' +
2634
+ '<td class="num">' + fmtNum(m.tokens.input) + '</td>' +
2635
+ '<td class="num">' + fmtNum(m.tokens.output) + '</td>' +
2636
+ '<td class="num">' + fmtUSD(m.costUSD) + '</td>' +
2637
+ '<td class="num">' + pct + '%' +
2638
+ '<span class="bar-wrap"><span class="bar-fill" style="width:' + barW + 'px"></span></span>' +
2639
+ '</td></tr>';
2640
+ });
2641
+ html += '</tbody></table>';
2642
+ modelWrap.innerHTML = html;
2643
+ }
2644
+
2645
+ // User table
2646
+ const userEntries = Object.entries(r.byUser);
2647
+ const usersSection = document.getElementById('users-section');
2648
+ usersSection.style.display = userEntries.length === 0 ? 'none' : '';
2649
+ if (userEntries.length > 0) {
2650
+ let html = '<table><thead><tr><th>User</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
2651
+ userEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
2652
+ html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
2653
+ });
2654
+ html += '</tbody></table>';
2655
+ document.getElementById('user-table-wrap').innerHTML = html;
2656
+ }
2657
+
2658
+ // Feature table
2659
+ const featureEntries = Object.entries(r.byFeature);
2660
+ const featuresSection = document.getElementById('features-section');
2661
+ featuresSection.style.display = featureEntries.length === 0 ? 'none' : '';
2662
+ if (featureEntries.length > 0) {
2663
+ let html = '<table><thead><tr><th>Feature</th><th class="num">Calls</th><th class="num">Cost</th></tr></thead><tbody>';
2664
+ featureEntries.slice().sort(function(a,b) { return b[1].costUSD - a[1].costUSD; }).forEach(function(e) {
2665
+ html += '<tr><td>' + escHtml(e[0]) + '</td><td class="num">' + fmtNum(e[1].calls) + '</td><td class="num">' + fmtUSD(e[1].costUSD) + '</td></tr>';
2666
+ });
2667
+ html += '</tbody></table>';
2668
+ document.getElementById('feature-table-wrap').innerHTML = html;
2669
+ }
2670
+
2671
+ // Forecast
2672
+ document.getElementById('fc-daily').textContent = fmtUSD(fc.projectedDailyCostUSD);
2673
+ document.getElementById('fc-monthly').textContent = fmtUSD(fc.projectedMonthlyCostUSD);
2674
+ document.getElementById('fc-burn').textContent = fmtUSD(fc.burnRatePerHour);
2675
+ document.getElementById('fc-window').textContent =
2676
+ fc.basedOnHours > 0 ? 'based on ' + fc.basedOnHours.toFixed(1) + 'h of data' : 'insufficient data';
2677
+
2678
+ // Footer
2679
+ document.getElementById('footer-updated').textContent =
2680
+ 'Last updated: ' + fmtDate(data.lastUpdated);
2681
+ }
2682
+
2683
+ // \u2500\u2500 Boot \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2684
+ reconnect();
2685
+ })();
2686
+ </script>
2687
+ </body>
2688
+ </html>`;
2689
+ }
2690
+
2691
+ // src/dashboard/server.ts
2692
+ function startDashboardServer(storage, port) {
2693
+ const server = createServer((req, res) => {
2694
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
2695
+ if (req.method === "GET" && url.pathname === "/") {
2696
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2697
+ res.end(getHtml(port));
2698
+ return;
2699
+ }
2700
+ if (req.method === "GET" && url.pathname === "/events") {
2701
+ const filter = url.searchParams.get("filter") ?? "24h";
2702
+ res.writeHead(200, {
2703
+ "Content-Type": "text/event-stream",
2704
+ "Cache-Control": "no-cache",
2705
+ "Connection": "keep-alive",
2706
+ "X-Accel-Buffering": "no"
2707
+ });
2708
+ res.flushHeaders();
2709
+ let lastFingerprint = "";
2710
+ async function sendData() {
2711
+ try {
2712
+ const data = await getDashboardData(storage, filter);
2713
+ const fp = getFingerprint(data);
2714
+ if (fp !== lastFingerprint) {
2715
+ lastFingerprint = fp;
2716
+ res.write(`data: ${JSON.stringify(data)}
2717
+
2718
+ `);
2719
+ }
2720
+ } catch {
2721
+ }
2722
+ }
2723
+ void sendData();
2724
+ const timer = setInterval(() => {
2725
+ void sendData();
2726
+ }, 3e3);
2727
+ res.on("close", () => {
2728
+ clearInterval(timer);
2729
+ });
2730
+ return;
2731
+ }
2732
+ res.writeHead(404, { "Content-Type": "text/plain" });
2733
+ res.end("Not found");
2734
+ });
2735
+ server.on("error", (err) => {
2736
+ if (err.code === "EADDRINUSE") {
2737
+ console.error(`[tokenwatch] Port ${port} is already in use. Try: tokenwatch dashboard --port <other>`);
2738
+ process.exit(1);
2739
+ }
2740
+ throw err;
2741
+ });
2742
+ server.listen(port, () => {
2743
+ console.log(`tokenwatch dashboard \u2192 http://localhost:${port}`);
2744
+ });
2745
+ }
2746
+
1929
2747
  // bin/cli.ts
1930
2748
  var __dirname = dirname(fileURLToPath(import.meta.url));
1931
2749
  var DB_PATH2 = join3(homedir3(), ".tokenwatch", "usage.db");
@@ -2014,20 +2832,36 @@ async function cmdReport() {
2014
2832
  }
2015
2833
  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");
2016
2834
  }
2835
+ async function cmdDashboard(port) {
2836
+ if (!existsSync2(DB_PATH2)) {
2837
+ console.log(`No SQLite database found at ${DB_PATH2}`);
2838
+ console.log("Start your app with storage: 'sqlite' to begin recording usage.");
2839
+ process.exit(1);
2840
+ }
2841
+ let storage;
2842
+ try {
2843
+ storage = new SqliteStorage(DB_PATH2);
2844
+ } catch {
2845
+ console.error("Failed to open SQLite database. Is better-sqlite3 installed?");
2846
+ console.error("Run: npm install better-sqlite3");
2847
+ process.exit(1);
2848
+ }
2849
+ startDashboardServer(storage, port);
2850
+ }
2017
2851
  function cmdHelp() {
2018
2852
  console.log(`
2019
2853
  tokenwatch \u2014 CLI
2020
2854
 
2021
2855
  Commands:
2022
- sync Fetch and cache latest model prices from remote
2023
- prices List all bundled models and their current prices
2024
- report Show last saved usage report (requires SQLite storage)
2025
- help Show this help message
2856
+ sync Fetch and cache latest model prices from remote
2857
+ prices List all bundled models and their current prices
2858
+ report Show last saved usage report (requires SQLite storage)
2859
+ dashboard [--port N] Open local web dashboard (default port: 4242)
2860
+ help Show this help message
2026
2861
  `.trim());
2027
2862
  }
2028
2863
  async function main() {
2029
2864
  const [, , cmd, ...args] = process.argv;
2030
- void args;
2031
2865
  switch (cmd) {
2032
2866
  case "sync":
2033
2867
  await cmdSync();
@@ -2038,6 +2872,12 @@ async function main() {
2038
2872
  case "report":
2039
2873
  await cmdReport();
2040
2874
  break;
2875
+ case "dashboard": {
2876
+ const portFlagIdx = args.indexOf("--port");
2877
+ const port = portFlagIdx !== -1 ? parseInt(args[portFlagIdx + 1] ?? "4242", 10) : 4242;
2878
+ await cmdDashboard(port);
2879
+ break;
2880
+ }
2041
2881
  case "help":
2042
2882
  case void 0:
2043
2883
  cmdHelp();