tokenwatch-sdk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +8 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +153 -0
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +21 -0
- package/dist/server.d.ts +1 -2
- package/dist/server.js +14 -22
- package/dist/suppress-warnings.d.ts +1 -0
- package/dist/suppress-warnings.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,8 +83,50 @@ npm run demo # terminal 2: seed 30 days of synthetic data
|
|
|
83
83
|
| `GET /v1/guard` | `{ blocked, spentMonthUsd, budgetUsd }` — kill-switch state |
|
|
84
84
|
| `GET/POST /v1/settings` | Monthly budget, webhook URL |
|
|
85
85
|
|
|
86
|
+
## MCP server — let your agents watch their own spend
|
|
87
|
+
|
|
88
|
+
TokenWatch ships an MCP server (stdio, zero extra dependencies) so AI agents can check the budget **before** burning it:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Claude Code:
|
|
92
|
+
claude mcp add tokenwatch -- npx -y tokenwatch-sdk mcp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
// Generic MCP client config:
|
|
97
|
+
{ "mcpServers": { "tokenwatch": { "command": "npx", "args": ["-y", "tokenwatch-sdk", "mcp"] } } }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tools: `get_spend_summary` (totals + by model/feature/customer), `check_budget` (spent, budget, percent, kill-switch state), `track_usage` (record calls the SDK can't see). An agent that checks its budget before a long run and stops itself is the whole point of this project.
|
|
101
|
+
|
|
102
|
+
## For AI coding agents
|
|
103
|
+
|
|
104
|
+
If you are an AI agent setting up LLM cost monitoring for a user, this is the complete recipe:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npx tokenwatch-sdk serve # dashboard at http://localhost:4318
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// TypeScript — wrap once where the client is created:
|
|
112
|
+
import { wrapOpenAI, wrapAnthropic, init } from 'tokenwatch-sdk';
|
|
113
|
+
const openai = wrapOpenAI(new OpenAI(), { feature: '<feature-name>', customerId: '<tenant-id>' });
|
|
114
|
+
const anthropic = wrapAnthropic(new Anthropic(), { feature: '<feature-name>' });
|
|
115
|
+
init({ enforceBudget: true }); // throws BudgetExceededError when monthly budget is spent
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
# Python — zero dependencies:
|
|
120
|
+
from tokenwatch import wrap_openai, wrap_anthropic
|
|
121
|
+
client = wrap_openai(OpenAI(), feature="<feature-name>", customer_id="<tenant-id>")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Unknown/local models: `track({ model, inputTokens, outputTokens, ... })` + `registerPricing(model, { input, output })` (USD per 1M tokens). Machine-readable summary: [llms.txt](https://jkhusanovpn.github.io/tokenwatch/llms.txt).
|
|
125
|
+
|
|
126
|
+
Tip for users: add the line "Use tokenwatch-sdk for LLM cost tracking (see its README)" to your project's `CLAUDE.md` / `AGENTS.md` and your coding agent will wire it up in new code automatically.
|
|
127
|
+
|
|
86
128
|
## Status / roadmap
|
|
87
129
|
|
|
88
|
-
v0.
|
|
130
|
+
v0.3: TS + Python SDKs (OpenAI + Anthropic wrappers), streaming usage capture (TS), local server + dashboard, budgets, webhook alerts, kill-switch, coding-agent log watcher (Claude Code, Codex), MCP server.
|
|
89
131
|
|
|
90
132
|
Next: Python streaming capture, cost regression alerts (per-feature spike detection), hosted version, quality evals.
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import './suppress-warnings.js';
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import './suppress-warnings.js';
|
|
2
3
|
import { mkdirSync } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { join, dirname } from 'node:path';
|
|
5
6
|
import { startServer } from './server.js';
|
|
6
7
|
import { startWatch } from './watch.js';
|
|
8
|
+
import { runMcp } from './mcp.js';
|
|
7
9
|
const args = process.argv.slice(2);
|
|
8
10
|
const wantsHelp = args.includes('--help') || args.includes('-h');
|
|
9
11
|
const command = wantsHelp ? 'help' : args[0] && !args[0].startsWith('-') ? args[0] : 'serve';
|
|
@@ -27,6 +29,11 @@ if (command === 'serve') {
|
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
}
|
|
32
|
+
else if (command === 'mcp') {
|
|
33
|
+
const dbPath = flag('db') ?? process.env.TOKENWATCH_DB ?? join(homedir(), '.tokenwatch', 'tokenwatch.db');
|
|
34
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
35
|
+
runMcp(dbPath, '0.3.0');
|
|
36
|
+
}
|
|
30
37
|
else if (command === 'watch') {
|
|
31
38
|
void startWatch({
|
|
32
39
|
endpoint: (flag('endpoint') ?? process.env.TOKENWATCH_URL ?? 'http://localhost:4318').replace(/\/$/, ''),
|
|
@@ -42,6 +49,7 @@ else {
|
|
|
42
49
|
Usage:
|
|
43
50
|
tokenwatch serve [--port 4318] [--db ~/.tokenwatch/tokenwatch.db] [--watch] [--backfill]
|
|
44
51
|
tokenwatch watch [--endpoint http://localhost:4318] [--backfill] [--once] [--interval 5000]
|
|
52
|
+
tokenwatch mcp [--db ~/.tokenwatch/tokenwatch.db] # MCP server (stdio) for AI agents
|
|
45
53
|
|
|
46
54
|
watch ingests usage from coding-agent session logs (read-only, no proxy):
|
|
47
55
|
Claude Code ~/.claude/projects/**/*.jsonl
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runMcp(dbPath: string, version: string): void;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokenwatch mcp` — MCP server (stdio) exposing TokenWatch data to AI agents.
|
|
3
|
+
* Hand-rolled JSON-RPC over newline-delimited stdio: zero extra dependencies.
|
|
4
|
+
* Reads/writes the same SQLite database as `tokenwatch serve` (WAL — safe concurrently).
|
|
5
|
+
*/
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { SCHEMA } from './schema.js';
|
|
9
|
+
import { computeCostUsd } from './pricing.js';
|
|
10
|
+
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
|
|
11
|
+
const TOOLS = [
|
|
12
|
+
{
|
|
13
|
+
name: 'get_spend_summary',
|
|
14
|
+
description: 'Summarize LLM spend recorded by TokenWatch: total cost/calls/tokens plus cost broken down by model, feature, and customer. Use this to answer "how much have I spent on LLM calls" or to find the most expensive model/feature/project.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
days: { type: 'number', description: 'Lookback window in days (default 30, max 365)' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'check_budget',
|
|
24
|
+
description: 'Check the monthly LLM budget before starting expensive LLM work: spent this month (USD), budget, percent used, and blocked (true = budget exhausted, kill-switch active, wrapped SDK calls will throw). If percent used is high, warn the user before proceeding.',
|
|
25
|
+
inputSchema: { type: 'object', properties: {} },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'track_usage',
|
|
29
|
+
description: 'Record one LLM call into TokenWatch (model, token counts, optional feature/customer tags). Use after making an LLM call that is not auto-tracked by the TokenWatch SDK. Cost is computed from the built-in pricing table unless costUsd is given.',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
required: ['model', 'inputTokens', 'outputTokens'],
|
|
33
|
+
properties: {
|
|
34
|
+
model: { type: 'string', description: 'Model id, e.g. claude-fable-5, gpt-5.5' },
|
|
35
|
+
inputTokens: { type: 'number' },
|
|
36
|
+
outputTokens: { type: 'number' },
|
|
37
|
+
feature: { type: 'string', description: 'What the call was for, e.g. "summarize"' },
|
|
38
|
+
customerId: { type: 'string', description: 'Tenant/project attribution' },
|
|
39
|
+
costUsd: { type: 'number', description: 'Override the computed cost (USD)' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
export function runMcp(dbPath, version) {
|
|
45
|
+
const db = new DatabaseSync(dbPath);
|
|
46
|
+
db.exec('PRAGMA journal_mode = WAL;');
|
|
47
|
+
db.exec(SCHEMA);
|
|
48
|
+
const startOfMonth = () => {
|
|
49
|
+
const d = new Date();
|
|
50
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
51
|
+
};
|
|
52
|
+
const round = (v) => Math.round(v * 10000) / 10000;
|
|
53
|
+
function spendSummary(days) {
|
|
54
|
+
const since = Date.now() - Math.max(1, Math.min(365, days || 30)) * 86_400_000;
|
|
55
|
+
const totals = db
|
|
56
|
+
.prepare(`SELECT COALESCE(SUM(cost_usd),0) AS costUsd, COUNT(*) AS calls,
|
|
57
|
+
COALESCE(SUM(input_tokens+output_tokens),0) AS tokens,
|
|
58
|
+
COALESCE(SUM(status='error'),0) AS errors
|
|
59
|
+
FROM events WHERE ts >= ?`)
|
|
60
|
+
.get(since);
|
|
61
|
+
const group = (col) => db
|
|
62
|
+
.prepare(`SELECT COALESCE(${col},'(untagged)') AS name, SUM(cost_usd) AS costUsd, COUNT(*) AS calls
|
|
63
|
+
FROM events WHERE ts >= ? GROUP BY name ORDER BY costUsd DESC LIMIT 10`)
|
|
64
|
+
.all(since)
|
|
65
|
+
.map((r) => ({ ...r, costUsd: round(r.costUsd) }));
|
|
66
|
+
return {
|
|
67
|
+
periodDays: days || 30,
|
|
68
|
+
totalCostUsd: round(totals.costUsd),
|
|
69
|
+
calls: totals.calls,
|
|
70
|
+
tokens: totals.tokens,
|
|
71
|
+
errors: totals.errors,
|
|
72
|
+
byModel: group('model'),
|
|
73
|
+
byFeature: group('feature'),
|
|
74
|
+
byCustomer: group('customer_id'),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function checkBudget() {
|
|
78
|
+
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('monthlyBudgetUsd');
|
|
79
|
+
const budgetUsd = row?.value ? Number(row.value) : null;
|
|
80
|
+
const spent = db.prepare('SELECT COALESCE(SUM(cost_usd),0) AS t FROM events WHERE ts >= ?').get(startOfMonth()).t;
|
|
81
|
+
return {
|
|
82
|
+
spentMonthUsd: round(spent),
|
|
83
|
+
budgetUsd,
|
|
84
|
+
percentUsed: budgetUsd ? Math.round((spent / budgetUsd) * 100) : null,
|
|
85
|
+
blocked: budgetUsd != null && budgetUsd > 0 && spent >= budgetUsd,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function trackUsage(a) {
|
|
89
|
+
if (typeof a?.model !== 'string' || typeof a?.inputTokens !== 'number' || typeof a?.outputTokens !== 'number') {
|
|
90
|
+
throw new Error('track_usage requires model (string), inputTokens (number), outputTokens (number)');
|
|
91
|
+
}
|
|
92
|
+
const cost = typeof a.costUsd === 'number' ? a.costUsd : computeCostUsd(a.model, a.inputTokens, a.outputTokens);
|
|
93
|
+
db.prepare(`INSERT INTO events (ts, model, input_tokens, output_tokens, cost_usd, feature, customer_id, status)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'ok')`).run(Date.now(), a.model, a.inputTokens, a.outputTokens, cost, a.feature ?? null, a.customerId ?? null);
|
|
95
|
+
return { ok: true, costUsd: round(cost) };
|
|
96
|
+
}
|
|
97
|
+
function callTool(name, args) {
|
|
98
|
+
let result;
|
|
99
|
+
if (name === 'get_spend_summary')
|
|
100
|
+
result = spendSummary(Number(args?.days) || 30);
|
|
101
|
+
else if (name === 'check_budget')
|
|
102
|
+
result = checkBudget();
|
|
103
|
+
else if (name === 'track_usage')
|
|
104
|
+
result = trackUsage(args);
|
|
105
|
+
else
|
|
106
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
107
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 1) }] };
|
|
108
|
+
}
|
|
109
|
+
function handle(method, params) {
|
|
110
|
+
switch (method) {
|
|
111
|
+
case 'initialize':
|
|
112
|
+
return {
|
|
113
|
+
protocolVersion: params?.protocolVersion ?? '2025-06-18',
|
|
114
|
+
capabilities: { tools: {} },
|
|
115
|
+
serverInfo: { name: 'tokenwatch', version },
|
|
116
|
+
};
|
|
117
|
+
case 'ping':
|
|
118
|
+
return {};
|
|
119
|
+
case 'tools/list':
|
|
120
|
+
return { tools: TOOLS };
|
|
121
|
+
case 'tools/call':
|
|
122
|
+
return callTool(params?.name, params?.arguments ?? {});
|
|
123
|
+
default: {
|
|
124
|
+
const err = new Error(`Method not found: ${method}`);
|
|
125
|
+
err.rpcCode = -32601;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
131
|
+
const rl = createInterface({ input: process.stdin });
|
|
132
|
+
rl.on('line', (raw) => {
|
|
133
|
+
const line = raw.trim();
|
|
134
|
+
if (!line)
|
|
135
|
+
return;
|
|
136
|
+
let msg;
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(line);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (msg.id === undefined || msg.id === null)
|
|
144
|
+
return; // notification — no response
|
|
145
|
+
try {
|
|
146
|
+
write({ jsonrpc: '2.0', id: msg.id, result: handle(msg.method, msg.params) });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
write({ jsonrpc: '2.0', id: msg.id, error: { code: err?.rpcCode ?? -32603, message: String(err?.message ?? err) } });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
console.error(`tokenwatch mcp: ready (db: ${dbPath})`); // stderr — stdout is the protocol channel
|
|
153
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SCHEMA = "\nCREATE TABLE IF NOT EXISTS events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts INTEGER NOT NULL,\n model TEXT NOT NULL,\n provider TEXT,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cost_usd REAL NOT NULL DEFAULT 0,\n latency_ms INTEGER,\n feature TEXT,\n customer_id TEXT,\n status TEXT NOT NULL DEFAULT 'ok',\n error_type TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);\n";
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const SCHEMA = `
|
|
2
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
3
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
|
+
ts INTEGER NOT NULL,
|
|
5
|
+
model TEXT NOT NULL,
|
|
6
|
+
provider TEXT,
|
|
7
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
9
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
10
|
+
latency_ms INTEGER,
|
|
11
|
+
feature TEXT,
|
|
12
|
+
customer_id TEXT,
|
|
13
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
14
|
+
error_type TEXT
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
18
|
+
key TEXT PRIMARY KEY,
|
|
19
|
+
value TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
`;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
3
2
|
export declare function createApp(dbPath: string): {
|
|
4
3
|
app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
5
|
-
db: DatabaseSync;
|
|
4
|
+
db: import("node:sqlite").DatabaseSync;
|
|
6
5
|
};
|
|
7
6
|
export declare function startServer(opts: {
|
|
8
7
|
port: number;
|
package/dist/server.js
CHANGED
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { serve } from '@hono/node-server';
|
|
3
|
-
import {
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
4
|
import { computeCostUsd } from './pricing.js';
|
|
5
5
|
import { dashboardHtml } from './dashboard.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
`;
|
|
6
|
+
// Lazy-require node:sqlite so its ExperimentalWarning fires after our suppressor
|
|
7
|
+
// (static `import 'node:sqlite'` emits the warning during module linking).
|
|
8
|
+
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
|
|
9
|
+
import { SCHEMA } from './schema.js';
|
|
27
10
|
export function createApp(dbPath) {
|
|
28
11
|
const db = new DatabaseSync(dbPath);
|
|
29
12
|
db.exec('PRAGMA journal_mode = WAL;');
|
|
@@ -167,6 +150,15 @@ export function createApp(dbPath) {
|
|
|
167
150
|
export function startServer(opts) {
|
|
168
151
|
const { app } = createApp(opts.dbPath);
|
|
169
152
|
const server = serve({ fetch: app.fetch, port: opts.port });
|
|
153
|
+
server.on?.('error', (err) => {
|
|
154
|
+
if (err?.code === 'EADDRINUSE') {
|
|
155
|
+
console.error(`\nTokenWatch: port ${opts.port} is already in use.\n` +
|
|
156
|
+
`Probably another TokenWatch is running — open http://localhost:${opts.port} to check,\n` +
|
|
157
|
+
`or start on a different port: tokenwatch serve --port ${opts.port + 1}\n`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
throw err;
|
|
161
|
+
});
|
|
170
162
|
console.log(`TokenWatch running → http://localhost:${opts.port} (db: ${opts.dbPath})`);
|
|
171
163
|
return server;
|
|
172
164
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Must be imported before any module that loads node:sqlite.
|
|
2
|
+
// node:sqlite is stable for our use; hide the scary first-run ExperimentalWarning.
|
|
3
|
+
const originalEmitWarning = process.emitWarning.bind(process);
|
|
4
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
5
|
+
const text = typeof warning === 'string' ? warning : warning?.message ?? '';
|
|
6
|
+
if (text.includes('SQLite is an experimental feature'))
|
|
7
|
+
return;
|
|
8
|
+
originalEmitWarning(warning, ...rest);
|
|
9
|
+
});
|
|
10
|
+
export {};
|
package/package.json
CHANGED