tokenwatch-sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javoxir Khusanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # TokenWatch
2
+
3
+ **Know where your LLM money goes.** Zero-config cost & quality monitor for indie AI builders: one-line SDK, local dashboard, per-feature/per-customer attribution, and a budget kill-switch so an agent loop can never surprise you with a 5-figure bill.
4
+
5
+ - No proxy in your request path — your calls go straight to the provider, telemetry is sent async on the side
6
+ - Single process, SQLite, zero native dependencies — `npx tokenwatch serve` and you're done
7
+ - Flat and simple, built for solo devs and small teams (not another per-seat enterprise platform)
8
+
9
+ ## Quickstart
10
+
11
+ ```bash
12
+ # 1. Start the server + dashboard (http://localhost:4318)
13
+ npx tokenwatch-sdk serve
14
+
15
+ # 2. Wrap your client (one line)
16
+ ```
17
+
18
+ ```ts
19
+ import OpenAI from 'openai';
20
+ import Anthropic from '@anthropic-ai/sdk';
21
+ import { wrapOpenAI, wrapAnthropic, init } from 'tokenwatch';
22
+
23
+ const openai = wrapOpenAI(new OpenAI(), { feature: 'chat' });
24
+ const anthropic = wrapAnthropic(new Anthropic(), { feature: 'summarize' });
25
+
26
+ // Optional: block calls when the monthly budget is spent
27
+ init({ enforceBudget: true });
28
+ ```
29
+
30
+ Every call is now tracked: model, tokens, cost (June 2026 pricing table, overridable), latency, errors — attributable by `feature` and `customerId` tags. Streaming calls are tracked too (via `stream.tee()` — your stream is untouched, usage is read from a mirrored branch).
31
+
32
+ ### Python
33
+
34
+ Zero-dependency Python SDK in [`python/`](python/):
35
+
36
+ ```python
37
+ from tokenwatch import wrap_openai, wrap_anthropic, init
38
+
39
+ client = wrap_openai(OpenAI(), feature="chat")
40
+ claude = wrap_anthropic(Anthropic(), feature="summarize")
41
+ init(enforce_budget=True) # optional kill-switch
42
+ ```
43
+
44
+ ### Manual tracking
45
+
46
+ ```ts
47
+ import { track } from 'tokenwatch';
48
+
49
+ track({ model: 'claude-fable-5', inputTokens: 1200, outputTokens: 400, feature: 'batch-job', customerId: 'acme' });
50
+ ```
51
+
52
+ ### Budgets & alerts
53
+
54
+ Set a monthly budget in the dashboard (or `POST /v1/settings`). At 80% and 100% TokenWatch fires your webhook; at 100% `enforceBudget: true` makes wrapped calls throw `BudgetExceededError` instead of burning money.
55
+
56
+ ## Demo
57
+
58
+ ```bash
59
+ npm run dev # terminal 1: server
60
+ npm run demo # terminal 2: seed 30 days of synthetic data
61
+ ```
62
+
63
+ ## API
64
+
65
+ | Endpoint | Description |
66
+ |---|---|
67
+ | `POST /v1/events` | Ingest events (bearer auth if `TOKENWATCH_API_KEY` is set) |
68
+ | `GET /v1/stats?days=30` | Aggregates: totals, by model/feature/customer, daily series |
69
+ | `GET /v1/guard` | `{ blocked, spentMonthUsd, budgetUsd }` — kill-switch state |
70
+ | `GET/POST /v1/settings` | Monthly budget, webhook URL |
71
+
72
+ ## Status / roadmap
73
+
74
+ v0.1 (MVP): TS + Python SDKs (OpenAI + Anthropic wrappers), streaming usage capture (TS), local server + dashboard, budgets, webhook alerts, kill-switch.
75
+
76
+ Next: Python streaming capture, cost regression alerts (per-feature spike detection), hosted version, quality evals.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, dirname } from 'node:path';
5
+ import { startServer } from './server.js';
6
+ const args = process.argv.slice(2);
7
+ const wantsHelp = args.includes('--help') || args.includes('-h');
8
+ const command = wantsHelp ? 'help' : args[0] && !args[0].startsWith('-') ? args[0] : 'serve';
9
+ function flag(name) {
10
+ const i = args.indexOf(`--${name}`);
11
+ return i >= 0 ? args[i + 1] : undefined;
12
+ }
13
+ if (command === 'serve') {
14
+ const port = Number(flag('port') ?? process.env.TOKENWATCH_PORT ?? process.env.PORT ?? 4318);
15
+ const dbPath = flag('db') ?? process.env.TOKENWATCH_DB ?? join(homedir(), '.tokenwatch', 'tokenwatch.db');
16
+ mkdirSync(dirname(dbPath), { recursive: true });
17
+ startServer({ port, dbPath });
18
+ }
19
+ else {
20
+ console.log(`tokenwatch — LLM cost & quality monitor
21
+
22
+ Usage:
23
+ tokenwatch serve [--port 4318] [--db ~/.tokenwatch/tokenwatch.db]
24
+
25
+ Env:
26
+ TOKENWATCH_PORT, TOKENWATCH_DB, TOKENWATCH_API_KEY (require bearer auth for ingestion)`);
27
+ }
@@ -0,0 +1 @@
1
+ export declare const dashboardHtml = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>TokenWatch</title>\n<script src=\"https://cdn.jsdelivr.net/npm/chart.js@4\"></script>\n<style>\n :root {\n --bg: #0d1117; --card: #161b22; --border: #30363d;\n --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;\n --green: #3fb950; --red: #f85149; --amber: #d29922;\n }\n * { box-sizing: border-box; }\n body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, 'Segoe UI', Roboto, sans-serif; }\n header { display: flex; align-items: center; gap: 16px; padding: 16px 24px; border-bottom: 1px solid var(--border); }\n header h1 { font-size: 18px; margin: 0; }\n header h1 span { color: var(--accent); }\n .spacer { flex: 1; }\n select, input, button { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; font: inherit; }\n button { cursor: pointer; } button:hover { border-color: var(--accent); }\n main { max-width: 1200px; margin: 0 auto; padding: 24px; }\n .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }\n .card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }\n .card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }\n .card .value { font-size: 26px; font-weight: 600; margin-top: 4px; }\n .card .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }\n .budgetbar { height: 8px; background: var(--border); border-radius: 4px; margin-top: 8px; overflow: hidden; }\n .budgetbar > div { height: 100%; background: var(--green); }\n .grid2 { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; margin-top: 12px; }\n .grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; margin-top: 12px; }\n h2 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 10px; }\n table { width: 100%; border-collapse: collapse; font-size: 13px; }\n th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); }\n th { color: var(--muted); font-weight: 500; }\n td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }\n .err { color: var(--red); }\n .ok { color: var(--green); }\n .chartbox { position: relative; height: 260px; }\n @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }\n</style>\n</head>\n<body>\n<header>\n <h1>\u23F1 Token<span>Watch</span></h1>\n <div class=\"spacer\"></div>\n <label>Period\n <select id=\"days\">\n <option value=\"7\">7 days</option>\n <option value=\"30\" selected>30 days</option>\n <option value=\"90\">90 days</option>\n </select>\n </label>\n <label>Budget $<input id=\"budget\" type=\"number\" min=\"0\" step=\"10\" style=\"width:90px\"></label>\n <button id=\"saveBudget\">Save</button>\n</header>\n<main>\n <div class=\"cards\">\n <div class=\"card\"><div class=\"label\">Spend (period)</div><div class=\"value\" id=\"spend\">\u2013</div><div class=\"sub\" id=\"calls\"></div></div>\n <div class=\"card\"><div class=\"label\">This month</div><div class=\"value\" id=\"month\">\u2013</div><div class=\"sub\" id=\"budgetState\"></div><div class=\"budgetbar\"><div id=\"budgetFill\" style=\"width:0%\"></div></div></div>\n <div class=\"card\"><div class=\"label\">Tokens</div><div class=\"value\" id=\"tokens\">\u2013</div><div class=\"sub\" id=\"tokensSplit\"></div></div>\n <div class=\"card\"><div class=\"label\">Avg latency</div><div class=\"value\" id=\"latency\">\u2013</div></div>\n <div class=\"card\"><div class=\"label\">Error rate</div><div class=\"value\" id=\"errors\">\u2013</div><div class=\"sub\" id=\"errorCount\"></div></div>\n </div>\n\n <div class=\"grid2\">\n <div class=\"card\"><h2>Daily spend</h2><div class=\"chartbox\"><canvas id=\"dailyChart\"></canvas></div></div>\n <div class=\"card\"><h2>By model</h2><div class=\"chartbox\"><canvas id=\"modelChart\"></canvas></div></div>\n </div>\n\n <div class=\"grid3\">\n <div class=\"card\"><h2>By feature</h2><table id=\"featureTable\"></table></div>\n <div class=\"card\"><h2>By customer</h2><table id=\"customerTable\"></table></div>\n </div>\n\n <div class=\"card\" style=\"margin-top:12px\"><h2>Recent calls</h2><table id=\"recentTable\"></table></div>\n</main>\n<script>\nconst $ = (id) => document.getElementById(id);\nconst usd = (v) => '$' + (v >= 100 ? v.toFixed(0) : v >= 1 ? v.toFixed(2) : v.toFixed(4));\nconst num = (v) => v >= 1e9 ? (v/1e9).toFixed(1)+'B' : v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(1)+'K' : String(v);\nlet dailyChart, modelChart;\n\nfunction fillTable(el, rows, nameHeader) {\n el.innerHTML = '<tr><th>' + nameHeader + '</th><th class=\"num\">Cost</th><th class=\"num\">Calls</th><th class=\"num\">Tokens</th></tr>' +\n rows.map(r => '<tr><td>' + r.name + '</td><td class=\"num\">' + usd(r.costUsd || 0) + '</td><td class=\"num\">' + num(r.calls) + '</td><td class=\"num\">' + num(r.tokens || 0) + '</td></tr>').join('');\n}\n\nasync function load() {\n const days = $('days').value;\n const s = await (await fetch('/v1/stats?days=' + days)).json();\n const t = s.totals;\n\n $('spend').textContent = usd(t.costUsd);\n $('calls').textContent = num(t.calls) + ' calls';\n $('tokens').textContent = num(t.inputTokens + t.outputTokens);\n $('tokensSplit').textContent = num(t.inputTokens) + ' in / ' + num(t.outputTokens) + ' out';\n $('latency').textContent = t.avgLatencyMs ? Math.round(t.avgLatencyMs) + ' ms' : '\u2013';\n const errRate = t.calls ? (100 * t.errors / t.calls) : 0;\n $('errors').textContent = errRate.toFixed(1) + '%';\n $('errors').className = 'value ' + (errRate > 5 ? 'err' : 'ok');\n $('errorCount').textContent = num(t.errors) + ' errors';\n\n $('month').textContent = usd(s.month.spentUsd);\n if (s.month.budgetUsd) {\n const pct = Math.min(100, 100 * s.month.spentUsd / s.month.budgetUsd);\n $('budgetFill').style.width = pct + '%';\n $('budgetFill').style.background = pct >= 100 ? 'var(--red)' : pct >= 80 ? 'var(--amber)' : 'var(--green)';\n $('budgetState').textContent = pct.toFixed(0) + '% of ' + usd(s.month.budgetUsd) + (pct >= 100 ? ' \u2014 BLOCKED' : '');\n if (!$('budget').matches(':focus')) $('budget').value = s.month.budgetUsd;\n } else {\n $('budgetState').textContent = 'no budget set';\n $('budgetFill').style.width = '0%';\n }\n\n const labels = s.daily.map(d => d.day.slice(5));\n dailyChart?.destroy();\n dailyChart = new Chart($('dailyChart'), {\n type: 'bar',\n data: { labels, datasets: [{ data: s.daily.map(d => d.costUsd), backgroundColor: '#58a6ff' }] },\n options: { plugins: { legend: { display: false } }, maintainAspectRatio: false,\n scales: { x: { grid: { display: false }, ticks: { color: '#8b949e' } }, y: { grid: { color: '#21262d' }, ticks: { color: '#8b949e', callback: v => '$' + v } } } }\n });\n\n modelChart?.destroy();\n modelChart = new Chart($('modelChart'), {\n type: 'doughnut',\n data: { labels: s.byModel.map(m => m.name), datasets: [{ data: s.byModel.map(m => m.costUsd),\n backgroundColor: ['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#39c5cf','#ff7b72','#7ee787'] }] },\n options: { maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#8b949e', boxWidth: 12 } } } }\n });\n\n fillTable($('featureTable'), s.byFeature, 'Feature');\n fillTable($('customerTable'), s.byCustomer, 'Customer');\n\n $('recentTable').innerHTML = '<tr><th>Time</th><th>Model</th><th>Feature</th><th>Customer</th><th class=\"num\">Tokens</th><th class=\"num\">Cost</th><th class=\"num\">ms</th><th>Status</th></tr>' +\n s.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + r.model + '</td><td>' + (r.feature || '\u2013') + '</td><td>' + (r.customerId || '\u2013') + '</td><td class=\"num\">' + num(r.inputTokens + r.outputTokens) + '</td><td class=\"num\">' + usd(r.costUsd) + '</td><td class=\"num\">' + (r.latencyMs ?? '\u2013') + '</td><td class=\"' + (r.status === 'error' ? 'err' : 'ok') + '\">' + (r.status === 'error' ? (r.errorType || 'error') : 'ok') + '</td></tr>').join('');\n}\n\n$('days').addEventListener('change', load);\n$('saveBudget').addEventListener('click', async () => {\n await fetch('/v1/settings', { method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ monthlyBudgetUsd: Number($('budget').value) || null }) });\n load();\n});\nload();\nsetInterval(load, 30000);\n</script>\n</body>\n</html>";
@@ -0,0 +1,150 @@
1
+ export const dashboardHtml = `<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>TokenWatch</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
8
+ <style>
9
+ :root {
10
+ --bg: #0d1117; --card: #161b22; --border: #30363d;
11
+ --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
12
+ --green: #3fb950; --red: #f85149; --amber: #d29922;
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, 'Segoe UI', Roboto, sans-serif; }
16
+ header { display: flex; align-items: center; gap: 16px; padding: 16px 24px; border-bottom: 1px solid var(--border); }
17
+ header h1 { font-size: 18px; margin: 0; }
18
+ header h1 span { color: var(--accent); }
19
+ .spacer { flex: 1; }
20
+ select, input, button { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; font: inherit; }
21
+ button { cursor: pointer; } button:hover { border-color: var(--accent); }
22
+ main { max-width: 1200px; margin: 0 auto; padding: 24px; }
23
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
24
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
25
+ .card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }
26
+ .card .value { font-size: 26px; font-weight: 600; margin-top: 4px; }
27
+ .card .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
28
+ .budgetbar { height: 8px; background: var(--border); border-radius: 4px; margin-top: 8px; overflow: hidden; }
29
+ .budgetbar > div { height: 100%; background: var(--green); }
30
+ .grid2 { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; margin-top: 12px; }
31
+ .grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; margin-top: 12px; }
32
+ h2 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 10px; }
33
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
34
+ th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); }
35
+ th { color: var(--muted); font-weight: 500; }
36
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
37
+ .err { color: var(--red); }
38
+ .ok { color: var(--green); }
39
+ .chartbox { position: relative; height: 260px; }
40
+ @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <header>
45
+ <h1>⏱ Token<span>Watch</span></h1>
46
+ <div class="spacer"></div>
47
+ <label>Period
48
+ <select id="days">
49
+ <option value="7">7 days</option>
50
+ <option value="30" selected>30 days</option>
51
+ <option value="90">90 days</option>
52
+ </select>
53
+ </label>
54
+ <label>Budget $<input id="budget" type="number" min="0" step="10" style="width:90px"></label>
55
+ <button id="saveBudget">Save</button>
56
+ </header>
57
+ <main>
58
+ <div class="cards">
59
+ <div class="card"><div class="label">Spend (period)</div><div class="value" id="spend">–</div><div class="sub" id="calls"></div></div>
60
+ <div class="card"><div class="label">This month</div><div class="value" id="month">–</div><div class="sub" id="budgetState"></div><div class="budgetbar"><div id="budgetFill" style="width:0%"></div></div></div>
61
+ <div class="card"><div class="label">Tokens</div><div class="value" id="tokens">–</div><div class="sub" id="tokensSplit"></div></div>
62
+ <div class="card"><div class="label">Avg latency</div><div class="value" id="latency">–</div></div>
63
+ <div class="card"><div class="label">Error rate</div><div class="value" id="errors">–</div><div class="sub" id="errorCount"></div></div>
64
+ </div>
65
+
66
+ <div class="grid2">
67
+ <div class="card"><h2>Daily spend</h2><div class="chartbox"><canvas id="dailyChart"></canvas></div></div>
68
+ <div class="card"><h2>By model</h2><div class="chartbox"><canvas id="modelChart"></canvas></div></div>
69
+ </div>
70
+
71
+ <div class="grid3">
72
+ <div class="card"><h2>By feature</h2><table id="featureTable"></table></div>
73
+ <div class="card"><h2>By customer</h2><table id="customerTable"></table></div>
74
+ </div>
75
+
76
+ <div class="card" style="margin-top:12px"><h2>Recent calls</h2><table id="recentTable"></table></div>
77
+ </main>
78
+ <script>
79
+ const $ = (id) => document.getElementById(id);
80
+ const usd = (v) => '$' + (v >= 100 ? v.toFixed(0) : v >= 1 ? v.toFixed(2) : v.toFixed(4));
81
+ const num = (v) => v >= 1e9 ? (v/1e9).toFixed(1)+'B' : v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(1)+'K' : String(v);
82
+ let dailyChart, modelChart;
83
+
84
+ function fillTable(el, rows, nameHeader) {
85
+ el.innerHTML = '<tr><th>' + nameHeader + '</th><th class="num">Cost</th><th class="num">Calls</th><th class="num">Tokens</th></tr>' +
86
+ rows.map(r => '<tr><td>' + r.name + '</td><td class="num">' + usd(r.costUsd || 0) + '</td><td class="num">' + num(r.calls) + '</td><td class="num">' + num(r.tokens || 0) + '</td></tr>').join('');
87
+ }
88
+
89
+ async function load() {
90
+ const days = $('days').value;
91
+ const s = await (await fetch('/v1/stats?days=' + days)).json();
92
+ const t = s.totals;
93
+
94
+ $('spend').textContent = usd(t.costUsd);
95
+ $('calls').textContent = num(t.calls) + ' calls';
96
+ $('tokens').textContent = num(t.inputTokens + t.outputTokens);
97
+ $('tokensSplit').textContent = num(t.inputTokens) + ' in / ' + num(t.outputTokens) + ' out';
98
+ $('latency').textContent = t.avgLatencyMs ? Math.round(t.avgLatencyMs) + ' ms' : '–';
99
+ const errRate = t.calls ? (100 * t.errors / t.calls) : 0;
100
+ $('errors').textContent = errRate.toFixed(1) + '%';
101
+ $('errors').className = 'value ' + (errRate > 5 ? 'err' : 'ok');
102
+ $('errorCount').textContent = num(t.errors) + ' errors';
103
+
104
+ $('month').textContent = usd(s.month.spentUsd);
105
+ if (s.month.budgetUsd) {
106
+ const pct = Math.min(100, 100 * s.month.spentUsd / s.month.budgetUsd);
107
+ $('budgetFill').style.width = pct + '%';
108
+ $('budgetFill').style.background = pct >= 100 ? 'var(--red)' : pct >= 80 ? 'var(--amber)' : 'var(--green)';
109
+ $('budgetState').textContent = pct.toFixed(0) + '% of ' + usd(s.month.budgetUsd) + (pct >= 100 ? ' — BLOCKED' : '');
110
+ if (!$('budget').matches(':focus')) $('budget').value = s.month.budgetUsd;
111
+ } else {
112
+ $('budgetState').textContent = 'no budget set';
113
+ $('budgetFill').style.width = '0%';
114
+ }
115
+
116
+ const labels = s.daily.map(d => d.day.slice(5));
117
+ dailyChart?.destroy();
118
+ dailyChart = new Chart($('dailyChart'), {
119
+ type: 'bar',
120
+ data: { labels, datasets: [{ data: s.daily.map(d => d.costUsd), backgroundColor: '#58a6ff' }] },
121
+ options: { plugins: { legend: { display: false } }, maintainAspectRatio: false,
122
+ scales: { x: { grid: { display: false }, ticks: { color: '#8b949e' } }, y: { grid: { color: '#21262d' }, ticks: { color: '#8b949e', callback: v => '$' + v } } } }
123
+ });
124
+
125
+ modelChart?.destroy();
126
+ modelChart = new Chart($('modelChart'), {
127
+ type: 'doughnut',
128
+ data: { labels: s.byModel.map(m => m.name), datasets: [{ data: s.byModel.map(m => m.costUsd),
129
+ backgroundColor: ['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#39c5cf','#ff7b72','#7ee787'] }] },
130
+ options: { maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#8b949e', boxWidth: 12 } } } }
131
+ });
132
+
133
+ fillTable($('featureTable'), s.byFeature, 'Feature');
134
+ fillTable($('customerTable'), s.byCustomer, 'Customer');
135
+
136
+ $('recentTable').innerHTML = '<tr><th>Time</th><th>Model</th><th>Feature</th><th>Customer</th><th class="num">Tokens</th><th class="num">Cost</th><th class="num">ms</th><th>Status</th></tr>' +
137
+ s.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + r.model + '</td><td>' + (r.feature || '–') + '</td><td>' + (r.customerId || '–') + '</td><td class="num">' + num(r.inputTokens + r.outputTokens) + '</td><td class="num">' + usd(r.costUsd) + '</td><td class="num">' + (r.latencyMs ?? '–') + '</td><td class="' + (r.status === 'error' ? 'err' : 'ok') + '">' + (r.status === 'error' ? (r.errorType || 'error') : 'ok') + '</td></tr>').join('');
138
+ }
139
+
140
+ $('days').addEventListener('change', load);
141
+ $('saveBudget').addEventListener('click', async () => {
142
+ await fetch('/v1/settings', { method: 'POST', headers: { 'content-type': 'application/json' },
143
+ body: JSON.stringify({ monthlyBudgetUsd: Number($('budget').value) || null }) });
144
+ load();
145
+ });
146
+ load();
147
+ setInterval(load, 30000);
148
+ </script>
149
+ </body>
150
+ </html>`;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * USD per 1M tokens. Prices as of June 2026 — verify against provider pricing
3
+ * pages before relying on exact numbers; override via registerPricing().
4
+ */
5
+ export interface ModelPrice {
6
+ input: number;
7
+ output: number;
8
+ }
9
+ /** Add or override pricing for a model (substring match against the model id). */
10
+ export declare function registerPricing(model: string, price: ModelPrice): void;
11
+ export declare function findPrice(model: string): ModelPrice | undefined;
12
+ /** Returns cost in USD, or 0 if the model is unknown (track it anyway). */
13
+ export declare function computeCostUsd(model: string, inputTokens: number, outputTokens: number): number;
@@ -0,0 +1,34 @@
1
+ const PRICING = {
2
+ 'claude-fable-5': { input: 10, output: 50 },
3
+ 'claude-opus-4-8': { input: 5, output: 25 },
4
+ 'claude-opus-4-7': { input: 5, output: 25 },
5
+ 'claude-opus-4-6': { input: 5, output: 25 },
6
+ 'claude-sonnet-4-6': { input: 3, output: 15 },
7
+ 'claude-haiku-4-5': { input: 1, output: 5 },
8
+ 'gpt-5.5-pro': { input: 30, output: 180 },
9
+ 'gpt-5.5': { input: 5, output: 30 },
10
+ 'gemini-3.5-flash': { input: 1.5, output: 9 },
11
+ 'gemini-3.1-pro': { input: 2, output: 12 },
12
+ 'grok-4.3': { input: 1.25, output: 2.5 },
13
+ 'deepseek-v4': { input: 0.3, output: 0.87 },
14
+ 'glm-5': { input: 0.6, output: 1.92 },
15
+ 'kimi-k2.6': { input: 0.6, output: 2.5 },
16
+ };
17
+ let keysByLength = Object.keys(PRICING).sort((a, b) => b.length - a.length);
18
+ /** Add or override pricing for a model (substring match against the model id). */
19
+ export function registerPricing(model, price) {
20
+ PRICING[model.toLowerCase()] = price;
21
+ keysByLength = Object.keys(PRICING).sort((a, b) => b.length - a.length);
22
+ }
23
+ export function findPrice(model) {
24
+ const m = model.toLowerCase();
25
+ const key = keysByLength.find((k) => m.includes(k));
26
+ return key ? PRICING[key] : undefined;
27
+ }
28
+ /** Returns cost in USD, or 0 if the model is unknown (track it anyway). */
29
+ export function computeCostUsd(model, inputTokens, outputTokens) {
30
+ const p = findPrice(model);
31
+ if (!p)
32
+ return 0;
33
+ return (inputTokens * p.input + outputTokens * p.output) / 1_000_000;
34
+ }
package/dist/sdk.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { registerPricing } from './pricing.js';
2
+ export { registerPricing };
3
+ export interface Tags {
4
+ feature?: string;
5
+ customerId?: string;
6
+ }
7
+ export interface TrackEvent extends Tags {
8
+ ts?: number;
9
+ model: string;
10
+ provider?: string;
11
+ inputTokens: number;
12
+ outputTokens: number;
13
+ costUsd?: number;
14
+ latencyMs?: number;
15
+ status?: 'ok' | 'error';
16
+ errorType?: string;
17
+ }
18
+ export interface InitOptions {
19
+ /** TokenWatch server URL. Default: $TOKENWATCH_URL or http://localhost:4318 */
20
+ endpoint?: string;
21
+ /** API key, if the server requires one ($TOKENWATCH_API_KEY). */
22
+ apiKey?: string;
23
+ /** Tags applied to every event (overridable per wrapper/track call). */
24
+ defaults?: Tags;
25
+ /** When true, wrapped calls throw BudgetExceededError once the monthly budget is spent. */
26
+ enforceBudget?: boolean;
27
+ }
28
+ export declare class BudgetExceededError extends Error {
29
+ constructor(spentUsd: number, budgetUsd: number);
30
+ }
31
+ export declare function init(opts: InitOptions): void;
32
+ export declare function track(event: TrackEvent): void;
33
+ export declare function flush(): Promise<void>;
34
+ export interface GuardState {
35
+ blocked: boolean;
36
+ spentMonthUsd: number;
37
+ budgetUsd: number | null;
38
+ }
39
+ export declare function guardBudget(): Promise<GuardState>;
40
+ /** Wrap an `openai` client: chat.completions.create and responses.create are tracked. */
41
+ export declare function wrapOpenAI<T extends object>(client: T, tags?: Tags): T;
42
+ /** Wrap an `@anthropic-ai/sdk` client: messages.create is tracked. */
43
+ export declare function wrapAnthropic<T extends object>(client: T, tags?: Tags): T;
package/dist/sdk.js ADDED
@@ -0,0 +1,202 @@
1
+ import { computeCostUsd, registerPricing } from './pricing.js';
2
+ export { registerPricing };
3
+ export class BudgetExceededError extends Error {
4
+ constructor(spentUsd, budgetUsd) {
5
+ super(`TokenWatch: monthly budget exceeded ($${spentUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}). Call blocked.`);
6
+ this.name = 'BudgetExceededError';
7
+ }
8
+ }
9
+ const config = {
10
+ endpoint: process.env.TOKENWATCH_URL ?? 'http://localhost:4318',
11
+ apiKey: process.env.TOKENWATCH_API_KEY,
12
+ defaults: {},
13
+ enforceBudget: false,
14
+ };
15
+ export function init(opts) {
16
+ if (opts.endpoint)
17
+ config.endpoint = opts.endpoint.replace(/\/$/, '');
18
+ if (opts.apiKey !== undefined)
19
+ config.apiKey = opts.apiKey;
20
+ if (opts.defaults)
21
+ config.defaults = opts.defaults;
22
+ if (opts.enforceBudget !== undefined)
23
+ config.enforceBudget = opts.enforceBudget;
24
+ }
25
+ // --- event queue -----------------------------------------------------------
26
+ let queue = [];
27
+ let timer = null;
28
+ export function track(event) {
29
+ const ev = {
30
+ ts: Date.now(),
31
+ status: 'ok',
32
+ ...config.defaults,
33
+ ...event,
34
+ };
35
+ ev.costUsd ??= computeCostUsd(ev.model, ev.inputTokens, ev.outputTokens);
36
+ queue.push(ev);
37
+ if (queue.length >= 25)
38
+ void flush();
39
+ else if (!timer) {
40
+ timer = setTimeout(() => {
41
+ timer = null;
42
+ void flush();
43
+ }, 2000);
44
+ timer.unref?.();
45
+ }
46
+ }
47
+ export async function flush() {
48
+ if (queue.length === 0)
49
+ return;
50
+ const batch = queue;
51
+ queue = [];
52
+ try {
53
+ const headers = { 'content-type': 'application/json' };
54
+ if (config.apiKey)
55
+ headers.authorization = `Bearer ${config.apiKey}`;
56
+ const res = await fetch(`${config.endpoint}/v1/events`, {
57
+ method: 'POST',
58
+ headers,
59
+ body: JSON.stringify({ events: batch }),
60
+ });
61
+ if (!res.ok)
62
+ throw new Error(`HTTP ${res.status}`);
63
+ }
64
+ catch (err) {
65
+ // Re-queue (bounded) so a transient outage doesn't lose data.
66
+ queue = batch.concat(queue).slice(0, 1000);
67
+ if (process.env.TOKENWATCH_DEBUG)
68
+ console.error('[tokenwatch] flush failed:', err);
69
+ }
70
+ }
71
+ let guardCache = null;
72
+ export async function guardBudget() {
73
+ const res = await fetch(`${config.endpoint}/v1/guard`);
74
+ if (!res.ok)
75
+ throw new Error(`TokenWatch guard check failed: HTTP ${res.status}`);
76
+ return (await res.json());
77
+ }
78
+ async function checkBudgetCached() {
79
+ if (guardCache && Date.now() - guardCache.at < 30_000)
80
+ return guardCache.state;
81
+ try {
82
+ const state = await guardBudget();
83
+ guardCache = { state, at: Date.now() };
84
+ return state;
85
+ }
86
+ catch {
87
+ return null; // fail open: never break the user's app because the monitor is down
88
+ }
89
+ }
90
+ function extractUsage(result) {
91
+ const u = result?.usage ?? {};
92
+ return {
93
+ // OpenAI chat completions / Anthropic + OpenAI responses API
94
+ inputTokens: u.prompt_tokens ?? u.input_tokens ?? 0,
95
+ outputTokens: u.completion_tokens ?? u.output_tokens ?? 0,
96
+ };
97
+ }
98
+ /** Pull usage out of stream events across providers (Anthropic message_start/delta, OpenAI chunks/responses). */
99
+ async function consumeStreamUsage(stream, provider, params, tags, t0) {
100
+ let inputTokens = 0;
101
+ let outputTokens = 0;
102
+ let model = params?.model ?? 'unknown';
103
+ try {
104
+ for await (const ev of stream) {
105
+ const u = ev?.usage ?? ev?.message?.usage ?? ev?.response?.usage;
106
+ if (u) {
107
+ // max(): Anthropic message_delta usage is cumulative; OpenAI sends usage once in the final chunk.
108
+ inputTokens = Math.max(inputTokens, u.input_tokens ?? u.prompt_tokens ?? 0);
109
+ outputTokens = Math.max(outputTokens, u.output_tokens ?? u.completion_tokens ?? 0);
110
+ }
111
+ model = ev?.model ?? ev?.message?.model ?? ev?.response?.model ?? model;
112
+ }
113
+ track({ model, provider, inputTokens, outputTokens, latencyMs: Date.now() - t0, ...tags });
114
+ }
115
+ catch (err) {
116
+ track({
117
+ model, provider, inputTokens, outputTokens,
118
+ latencyMs: Date.now() - t0,
119
+ status: 'error',
120
+ errorType: err?.constructor?.name ?? 'Error',
121
+ ...tags,
122
+ });
123
+ }
124
+ }
125
+ function instrument(fn, self, provider, tags) {
126
+ return async function tracked(params, ...rest) {
127
+ if (config.enforceBudget) {
128
+ const state = await checkBudgetCached();
129
+ if (state?.blocked)
130
+ throw new BudgetExceededError(state.spentMonthUsd, state.budgetUsd ?? 0);
131
+ }
132
+ // Streaming: tee the stream — hand one branch back untouched, read usage from the other.
133
+ if (params && typeof params === 'object' && params.stream) {
134
+ const t0 = Date.now();
135
+ const stream = await fn.call(self, params, ...rest);
136
+ if (typeof stream?.tee === 'function') {
137
+ const [mine, theirs] = stream.tee();
138
+ void consumeStreamUsage(mine, provider, params, tags, t0);
139
+ return theirs;
140
+ }
141
+ return stream; // unknown stream shape — pass through untracked
142
+ }
143
+ const t0 = Date.now();
144
+ try {
145
+ const result = await fn.call(self, params, ...rest);
146
+ const usage = extractUsage(result);
147
+ track({
148
+ model: result?.model ?? params?.model ?? 'unknown',
149
+ provider,
150
+ ...usage,
151
+ latencyMs: Date.now() - t0,
152
+ ...tags,
153
+ });
154
+ return result;
155
+ }
156
+ catch (err) {
157
+ track({
158
+ model: params?.model ?? 'unknown',
159
+ provider,
160
+ inputTokens: 0,
161
+ outputTokens: 0,
162
+ latencyMs: Date.now() - t0,
163
+ status: 'error',
164
+ errorType: err?.constructor?.name ?? 'Error',
165
+ ...tags,
166
+ });
167
+ throw err;
168
+ }
169
+ };
170
+ }
171
+ function wrapPaths(obj, paths, provider, tags) {
172
+ const heads = new Map();
173
+ for (const [head, ...rest] of paths) {
174
+ if (!heads.has(head))
175
+ heads.set(head, []);
176
+ heads.get(head).push(rest);
177
+ }
178
+ return new Proxy(obj, {
179
+ get(target, prop) {
180
+ const value = Reflect.get(target, prop, target);
181
+ const key = String(prop);
182
+ const rests = heads.get(key);
183
+ if (rests) {
184
+ if (rests.some((r) => r.length === 0) && typeof value === 'function') {
185
+ return instrument(value, target, provider, tags);
186
+ }
187
+ if (value && typeof value === 'object') {
188
+ return wrapPaths(value, rests, provider, tags);
189
+ }
190
+ }
191
+ return typeof value === 'function' ? value.bind(target) : value;
192
+ },
193
+ });
194
+ }
195
+ /** Wrap an `openai` client: chat.completions.create and responses.create are tracked. */
196
+ export function wrapOpenAI(client, tags = {}) {
197
+ return wrapPaths(client, [['chat', 'completions', 'create'], ['responses', 'create']], 'openai', tags);
198
+ }
199
+ /** Wrap an `@anthropic-ai/sdk` client: messages.create is tracked. */
200
+ export function wrapAnthropic(client, tags = {}) {
201
+ return wrapPaths(client, [['messages', 'create']], 'anthropic', tags);
202
+ }
@@ -0,0 +1,10 @@
1
+ import { Hono } from 'hono';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ export declare function createApp(dbPath: string): {
4
+ app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
5
+ db: DatabaseSync;
6
+ };
7
+ export declare function startServer(opts: {
8
+ port: number;
9
+ dbPath: string;
10
+ }): import("@hono/node-server").ServerType;
package/dist/server.js ADDED
@@ -0,0 +1,172 @@
1
+ import { Hono } from 'hono';
2
+ import { serve } from '@hono/node-server';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { computeCostUsd } from './pricing.js';
5
+ import { dashboardHtml } from './dashboard.js';
6
+ const SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS events (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ ts INTEGER NOT NULL,
10
+ model TEXT NOT NULL,
11
+ provider TEXT,
12
+ input_tokens INTEGER NOT NULL DEFAULT 0,
13
+ output_tokens INTEGER NOT NULL DEFAULT 0,
14
+ cost_usd REAL NOT NULL DEFAULT 0,
15
+ latency_ms INTEGER,
16
+ feature TEXT,
17
+ customer_id TEXT,
18
+ status TEXT NOT NULL DEFAULT 'ok',
19
+ error_type TEXT
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
22
+ CREATE TABLE IF NOT EXISTS settings (
23
+ key TEXT PRIMARY KEY,
24
+ value TEXT NOT NULL
25
+ );
26
+ `;
27
+ export function createApp(dbPath) {
28
+ const db = new DatabaseSync(dbPath);
29
+ db.exec('PRAGMA journal_mode = WAL;');
30
+ db.exec(SCHEMA);
31
+ const insertStmt = db.prepare(`INSERT INTO events (ts, model, provider, input_tokens, output_tokens, cost_usd, latency_ms, feature, customer_id, status, error_type)
32
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
33
+ const getSetting = (key) => {
34
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
35
+ return row?.value ?? null;
36
+ };
37
+ const setSetting = (key, value) => db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
38
+ const startOfMonth = () => {
39
+ const d = new Date();
40
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
41
+ };
42
+ const monthSpend = () => {
43
+ const row = db.prepare('SELECT COALESCE(SUM(cost_usd), 0) AS total FROM events WHERE ts >= ?').get(startOfMonth());
44
+ return row.total;
45
+ };
46
+ const budgetUsd = () => {
47
+ const v = getSetting('monthlyBudgetUsd');
48
+ return v ? Number(v) : null;
49
+ };
50
+ function maybeFireBudgetAlerts() {
51
+ const budget = budgetUsd();
52
+ const webhook = getSetting('webhookUrl');
53
+ if (!budget || !webhook)
54
+ return;
55
+ const spent = monthSpend();
56
+ const monthKey = new Date().toISOString().slice(0, 7);
57
+ for (const threshold of [0.8, 1.0]) {
58
+ if (spent < budget * threshold)
59
+ continue;
60
+ const alertKey = `alerted_${threshold}_${monthKey}`;
61
+ if (getSetting(alertKey))
62
+ continue;
63
+ setSetting(alertKey, String(Date.now()));
64
+ fetch(webhook, {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json' },
67
+ body: JSON.stringify({
68
+ source: 'tokenwatch',
69
+ alert: threshold >= 1.0 ? 'budget_exceeded' : 'budget_80_percent',
70
+ spentUsd: Number(spent.toFixed(4)),
71
+ budgetUsd: budget,
72
+ month: monthKey,
73
+ }),
74
+ }).catch(() => { });
75
+ }
76
+ }
77
+ const app = new Hono();
78
+ // Optional bearer auth for ingestion when TOKENWATCH_API_KEY is set.
79
+ app.use('/v1/events', async (c, next) => {
80
+ const required = process.env.TOKENWATCH_API_KEY;
81
+ if (required && c.req.header('authorization') !== `Bearer ${required}`) {
82
+ return c.json({ error: 'unauthorized' }, 401);
83
+ }
84
+ await next();
85
+ });
86
+ app.post('/v1/events', async (c) => {
87
+ const body = await c.req.json().catch(() => null);
88
+ if (!body?.events || !Array.isArray(body.events)) {
89
+ return c.json({ error: 'expected { events: [...] }' }, 400);
90
+ }
91
+ db.exec('BEGIN');
92
+ try {
93
+ for (const e of body.events) {
94
+ if (!e || typeof e.model !== 'string')
95
+ continue;
96
+ const inputTokens = Number(e.inputTokens) || 0;
97
+ const outputTokens = Number(e.outputTokens) || 0;
98
+ insertStmt.run(Number(e.ts) || Date.now(), e.model, e.provider ?? null, inputTokens, outputTokens, typeof e.costUsd === 'number' ? e.costUsd : computeCostUsd(e.model, inputTokens, outputTokens), e.latencyMs != null ? Number(e.latencyMs) : null, e.feature ?? null, e.customerId ?? null, e.status === 'error' ? 'error' : 'ok', e.errorType ?? null);
99
+ }
100
+ db.exec('COMMIT');
101
+ }
102
+ catch (err) {
103
+ db.exec('ROLLBACK');
104
+ throw err;
105
+ }
106
+ maybeFireBudgetAlerts();
107
+ return c.json({ ok: true, ingested: body.events.length });
108
+ });
109
+ app.get('/v1/stats', (c) => {
110
+ const days = Math.max(1, Math.min(365, Number(c.req.query('days')) || 30));
111
+ const since = Date.now() - days * 86_400_000;
112
+ const totals = db
113
+ .prepare(`SELECT COALESCE(SUM(cost_usd),0) AS costUsd, COUNT(*) AS calls,
114
+ COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens,
115
+ AVG(latency_ms) AS avgLatencyMs, COALESCE(SUM(status = 'error'),0) AS errors
116
+ FROM events WHERE ts >= ?`)
117
+ .get(since);
118
+ const groupBy = (col) => db
119
+ .prepare(`SELECT COALESCE(${col}, '(untagged)') AS name, SUM(cost_usd) AS costUsd, COUNT(*) AS calls,
120
+ SUM(input_tokens + output_tokens) AS tokens
121
+ FROM events WHERE ts >= ? GROUP BY name ORDER BY costUsd DESC LIMIT 25`)
122
+ .all(since);
123
+ const daily = db
124
+ .prepare(`SELECT date(ts / 1000, 'unixepoch') AS day, SUM(cost_usd) AS costUsd, COUNT(*) AS calls
125
+ FROM events WHERE ts >= ? GROUP BY day ORDER BY day`)
126
+ .all(since);
127
+ const recent = db
128
+ .prepare(`SELECT ts, model, provider, input_tokens AS inputTokens, output_tokens AS outputTokens,
129
+ cost_usd AS costUsd, latency_ms AS latencyMs, feature, customer_id AS customerId, status, error_type AS errorType
130
+ FROM events ORDER BY ts DESC LIMIT 50`)
131
+ .all();
132
+ return c.json({
133
+ periodDays: days,
134
+ totals,
135
+ byModel: groupBy('model'),
136
+ byFeature: groupBy('feature'),
137
+ byCustomer: groupBy('customer_id'),
138
+ daily,
139
+ recent,
140
+ month: { spentUsd: monthSpend(), budgetUsd: budgetUsd() },
141
+ });
142
+ });
143
+ app.get('/v1/guard', (c) => {
144
+ const budget = budgetUsd();
145
+ const spent = monthSpend();
146
+ return c.json({
147
+ blocked: budget != null && budget > 0 && spent >= budget,
148
+ spentMonthUsd: spent,
149
+ budgetUsd: budget,
150
+ });
151
+ });
152
+ app.get('/v1/settings', (c) => c.json({ monthlyBudgetUsd: budgetUsd(), webhookUrl: getSetting('webhookUrl') }));
153
+ app.post('/v1/settings', async (c) => {
154
+ const body = await c.req.json().catch(() => null);
155
+ if (!body)
156
+ return c.json({ error: 'invalid json' }, 400);
157
+ if ('monthlyBudgetUsd' in body)
158
+ setSetting('monthlyBudgetUsd', body.monthlyBudgetUsd ? String(body.monthlyBudgetUsd) : '');
159
+ if ('webhookUrl' in body)
160
+ setSetting('webhookUrl', body.webhookUrl ?? '');
161
+ return c.json({ ok: true });
162
+ });
163
+ app.get('/healthz', (c) => c.json({ ok: true }));
164
+ app.get('/', (c) => c.html(dashboardHtml));
165
+ return { app, db };
166
+ }
167
+ export function startServer(opts) {
168
+ const { app } = createApp(opts.dbPath);
169
+ const server = serve({ fetch: app.fetch, port: opts.port });
170
+ console.log(`TokenWatch running → http://localhost:${opts.port} (db: ${opts.dbPath})`);
171
+ return server;
172
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "tokenwatch-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Zero-config LLM cost & quality monitor for indie AI builders. One-line SDK, local dashboard, budget kill-switch.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "llm",
9
+ "observability",
10
+ "cost-tracking",
11
+ "openai",
12
+ "anthropic",
13
+ "claude",
14
+ "monitoring",
15
+ "helicone-alternative"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/jkhusanovpn/tokenwatch.git"
20
+ },
21
+ "bin": {
22
+ "tokenwatch": "dist/cli.js"
23
+ },
24
+ "main": "./dist/sdk.js",
25
+ "types": "./dist/sdk.d.ts",
26
+ "exports": {
27
+ ".": "./dist/sdk.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "prepublishOnly": "npm run build",
36
+ "dev": "tsx src/cli.ts serve",
37
+ "build": "tsc -p tsconfig.json",
38
+ "demo": "tsx scripts/demo.ts",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "@hono/node-server": "^1.13.0",
43
+ "hono": "^4.6.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^24.0.0",
47
+ "tsx": "^4.19.0",
48
+ "typescript": "^5.6.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.5"
52
+ }
53
+ }