@diogonzafe/tokenwatch 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/README.md +250 -0
- package/dist/cli.cjs +112 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +110 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +599 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +166 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/prices.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# tokenwatch
|
|
2
|
+
|
|
3
|
+
Transparent TypeScript wrapper that intercepts LLM API calls and tracks cost in real-time by session, user and model — without changing anything in your existing code.
|
|
4
|
+
|
|
5
|
+
Supports **OpenAI**, **Anthropic**, **Google Gemini** and **DeepSeek**.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install tokenwatch
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies (install only what you use):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install openai # OpenAI / DeepSeek
|
|
17
|
+
npm install @anthropic-ai/sdk # Anthropic
|
|
18
|
+
npm install @google/generative-ai # Gemini
|
|
19
|
+
npm install better-sqlite3 # optional — only for storage: 'sqlite'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createTracker } from 'tokenwatch'
|
|
28
|
+
|
|
29
|
+
const tracker = await createTracker({
|
|
30
|
+
// All fields are optional
|
|
31
|
+
storage: 'memory', // 'memory' (default) | 'sqlite'
|
|
32
|
+
alertThreshold: 1.00, // USD — fires webhookUrl when exceeded
|
|
33
|
+
webhookUrl: 'https://...', // Discord / Slack webhook
|
|
34
|
+
syncPrices: true, // fetch fresh prices from GitHub (default: true)
|
|
35
|
+
customPrices: {
|
|
36
|
+
'my-model': { input: 0.001, output: 0.002 } // USD per 1M tokens
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## OpenAI
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import OpenAI from 'openai'
|
|
47
|
+
import { wrapOpenAI } from 'tokenwatch'
|
|
48
|
+
|
|
49
|
+
const openai = wrapOpenAI(new OpenAI(), tracker)
|
|
50
|
+
|
|
51
|
+
const res = await openai.chat.completions.create({
|
|
52
|
+
model: 'gpt-4o',
|
|
53
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
54
|
+
// Optional — removed before sending to the API
|
|
55
|
+
__sessionId: 'session_abc',
|
|
56
|
+
__userId: 'user_123',
|
|
57
|
+
})
|
|
58
|
+
// res is identical to the original OpenAI response — zero difference
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Streaming is supported:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const stream = await openai.chat.completions.create({
|
|
65
|
+
model: 'gpt-4o',
|
|
66
|
+
messages: [...],
|
|
67
|
+
stream: true,
|
|
68
|
+
stream_options: { include_usage: true }, // required for usage in stream
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
for await (const chunk of stream) {
|
|
72
|
+
process.stdout.write(chunk.choices[0]?.delta?.content ?? '')
|
|
73
|
+
}
|
|
74
|
+
// Cost tracked automatically from the final chunk
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Anthropic
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
83
|
+
import { wrapAnthropic } from 'tokenwatch'
|
|
84
|
+
|
|
85
|
+
const anthropic = wrapAnthropic(new Anthropic(), tracker)
|
|
86
|
+
|
|
87
|
+
const res = await anthropic.messages.create({
|
|
88
|
+
model: 'claude-sonnet-4-6',
|
|
89
|
+
max_tokens: 1024,
|
|
90
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
91
|
+
__sessionId: 'session_abc',
|
|
92
|
+
__userId: 'user_123',
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Google Gemini
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { GoogleGenerativeAI } from '@google/generative-ai'
|
|
102
|
+
import { wrapGemini } from 'tokenwatch'
|
|
103
|
+
|
|
104
|
+
const genAI = wrapGemini(new GoogleGenerativeAI(process.env.GEMINI_API_KEY!), tracker)
|
|
105
|
+
|
|
106
|
+
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' })
|
|
107
|
+
const result = await model.generateContent('Explain quantum computing')
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## DeepSeek
|
|
113
|
+
|
|
114
|
+
DeepSeek uses an OpenAI-compatible API — just set `baseURL`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import OpenAI from 'openai'
|
|
118
|
+
import { wrapDeepSeek } from 'tokenwatch'
|
|
119
|
+
|
|
120
|
+
const deepseek = wrapDeepSeek(
|
|
121
|
+
new OpenAI({
|
|
122
|
+
baseURL: 'https://api.deepseek.com',
|
|
123
|
+
apiKey: process.env.DEEPSEEK_API_KEY!,
|
|
124
|
+
}),
|
|
125
|
+
tracker,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const res = await deepseek.chat.completions.create({
|
|
129
|
+
model: 'deepseek-chat',
|
|
130
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Reports
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
tracker.getReport()
|
|
140
|
+
// {
|
|
141
|
+
// totalCostUSD: 0.087,
|
|
142
|
+
// totalTokens: { input: 24000, output: 6000 },
|
|
143
|
+
// byModel: {
|
|
144
|
+
// 'gpt-4o': { costUSD: 0.062, calls: 5, tokens: { input: 20000, output: 5000 } },
|
|
145
|
+
// 'claude-sonnet-4-6': { costUSD: 0.025, calls: 2, tokens: { input: 4000, output: 1000 } }
|
|
146
|
+
// },
|
|
147
|
+
// bySession: { 'session_abc': { costUSD: 0.045, calls: 4 } },
|
|
148
|
+
// byUser: { 'user_123': { costUSD: 0.087, calls: 7 } },
|
|
149
|
+
// period: { from: '2026-04-16T10:00:00Z', to: '2026-04-16T11:00:00Z' }
|
|
150
|
+
// }
|
|
151
|
+
|
|
152
|
+
tracker.reset() // clear all data
|
|
153
|
+
tracker.resetSession('session_abc') // clear one session
|
|
154
|
+
tracker.exportJSON() // full report as JSON string
|
|
155
|
+
tracker.exportCSV() // all calls as CSV string
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Price Resolution
|
|
161
|
+
|
|
162
|
+
Prices are resolved in this priority order:
|
|
163
|
+
|
|
164
|
+
1. **`customPrices`** — your own overrides, highest priority
|
|
165
|
+
2. **Remote `prices.json`** — fetched from GitHub, cached for 24h in `~/.tokenwatch/prices.json`
|
|
166
|
+
3. **Bundled `prices.json`** — always-present fallback, updated weekly via GitHub Action
|
|
167
|
+
|
|
168
|
+
If a model is not found in any layer, cost is recorded as **$0** with a `console.warn`.
|
|
169
|
+
|
|
170
|
+
Prices are in **USD per 1 million tokens**.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## SQLite Storage
|
|
175
|
+
|
|
176
|
+
For persistent tracking across restarts:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm install better-sqlite3
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const tracker = await createTracker({ storage: 'sqlite' })
|
|
184
|
+
// Data stored in ~/.tokenwatch/usage.db
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Alerts & Webhooks
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const tracker = await createTracker({
|
|
193
|
+
alertThreshold: 5.00, // USD
|
|
194
|
+
webhookUrl: 'https://hooks.slack.com/...', // or Discord
|
|
195
|
+
})
|
|
196
|
+
// Webhook fires once when totalCostUSD crosses the threshold
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Webhook payload:
|
|
200
|
+
```json
|
|
201
|
+
{ "text": "[tokenwatch] Alert: total cost reached $5.0012 USD (threshold: $5)" }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## CLI
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
npx tokenwatch sync # force update cached prices from remote
|
|
210
|
+
npx tokenwatch prices # list all models and current prices
|
|
211
|
+
npx tokenwatch report # show last saved report (SQLite)
|
|
212
|
+
npx tokenwatch help # show help
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Bundled Models
|
|
218
|
+
|
|
219
|
+
| Model | Input ($/1M) | Output ($/1M) |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| gpt-4o | $2.50 | $10.00 |
|
|
222
|
+
| gpt-4o-mini | $0.15 | $0.60 |
|
|
223
|
+
| gpt-5 | $1.25 | $10.00 |
|
|
224
|
+
| gpt-5-mini | $0.25 | $2.00 |
|
|
225
|
+
| gpt-5-nano | $0.05 | $0.40 |
|
|
226
|
+
| claude-opus-4-6 | $5.00 | $25.00 |
|
|
227
|
+
| claude-sonnet-4-6 | $3.00 | $15.00 |
|
|
228
|
+
| claude-haiku-4-5 | $1.00 | $5.00 |
|
|
229
|
+
| gemini-2.5-pro | $1.25 | $10.00 |
|
|
230
|
+
| gemini-2.5-flash | $0.30 | $2.50 |
|
|
231
|
+
| deepseek-chat | $0.28 | $0.42 |
|
|
232
|
+
| deepseek-reasoner | $0.55 | $2.19 |
|
|
233
|
+
|
|
234
|
+
Prices are automatically updated every Monday via GitHub Action.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Behaviour Guarantees
|
|
239
|
+
|
|
240
|
+
- `__sessionId` and `__userId` are **stripped before** the request reaches the API
|
|
241
|
+
- The response object returned is **identical** to the original SDK response
|
|
242
|
+
- Tracking operations are **synchronous and non-blocking** — zero latency added
|
|
243
|
+
- If the API call **fails**, no cost is recorded and the original error is re-thrown unchanged
|
|
244
|
+
- Streaming is fully supported — usage is accumulated from the final stream event
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// bin/cli.ts
|
|
5
|
+
var import_node_fs2 = require("fs");
|
|
6
|
+
var import_node_path2 = require("path");
|
|
7
|
+
var import_node_url = require("url");
|
|
8
|
+
|
|
9
|
+
// src/core/sync.ts
|
|
10
|
+
var import_promises = require("fs/promises");
|
|
11
|
+
var import_node_fs = require("fs");
|
|
12
|
+
var import_node_os = require("os");
|
|
13
|
+
var import_node_path = require("path");
|
|
14
|
+
var CACHE_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".tokenwatch");
|
|
15
|
+
var CACHE_FILE = (0, import_node_path.join)(CACHE_DIR, "prices.json");
|
|
16
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
17
|
+
var REMOTE_URL = "https://raw.githubusercontent.com/diogonzafe/tokenwatch/main/prices.json";
|
|
18
|
+
async function fetchRemotePrices(url = REMOTE_URL) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8e3) });
|
|
21
|
+
if (!res.ok) return null;
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
if (!data?.models) return null;
|
|
24
|
+
await persistCache(data);
|
|
25
|
+
return data.models;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function persistCache(data) {
|
|
31
|
+
try {
|
|
32
|
+
await (0, import_promises.mkdir)(CACHE_DIR, { recursive: true });
|
|
33
|
+
const payload = { ...data, _cachedAt: Date.now() };
|
|
34
|
+
await (0, import_promises.writeFile)(CACHE_FILE, JSON.stringify(payload, null, 2), "utf8");
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// bin/cli.ts
|
|
40
|
+
var import_meta = {};
|
|
41
|
+
var __dirname = (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
42
|
+
function loadBundledPrices() {
|
|
43
|
+
const pricesPath = (0, import_node_path2.join)(__dirname, "..", "prices.json");
|
|
44
|
+
const raw = (0, import_node_fs2.readFileSync)(pricesPath, "utf8");
|
|
45
|
+
const data = JSON.parse(raw);
|
|
46
|
+
return data.models;
|
|
47
|
+
}
|
|
48
|
+
async function cmdSync() {
|
|
49
|
+
console.log("Fetching latest prices from remote...");
|
|
50
|
+
const result = await fetchRemotePrices();
|
|
51
|
+
if (result) {
|
|
52
|
+
console.log(`\u2713 Prices updated. ${Object.keys(result).length} models cached.`);
|
|
53
|
+
} else {
|
|
54
|
+
console.error("\u2717 Failed to fetch remote prices. Check your internet connection.");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function cmdPrices() {
|
|
59
|
+
const models = loadBundledPrices();
|
|
60
|
+
const rows = Object.entries(models).map(([name, price]) => ({
|
|
61
|
+
model: name,
|
|
62
|
+
input: `$${price.input.toFixed(2)}/M`,
|
|
63
|
+
output: `$${price.output.toFixed(2)}/M`
|
|
64
|
+
}));
|
|
65
|
+
const maxName = Math.max(...rows.map((r) => r.model.length), 5);
|
|
66
|
+
const header = `${"Model".padEnd(maxName)} ${"Input".padStart(12)} ${"Output".padStart(12)}`;
|
|
67
|
+
const sep = "-".repeat(header.length);
|
|
68
|
+
console.log(header);
|
|
69
|
+
console.log(sep);
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
console.log(`${row.model.padEnd(maxName)} ${row.input.padStart(12)} ${row.output.padStart(12)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function cmdHelp() {
|
|
75
|
+
console.log(`
|
|
76
|
+
tokenwatch \u2014 CLI
|
|
77
|
+
|
|
78
|
+
Commands:
|
|
79
|
+
sync Fetch and cache latest model prices from remote
|
|
80
|
+
prices List all bundled models and their current prices
|
|
81
|
+
report Show last saved usage report (requires SQLite storage)
|
|
82
|
+
help Show this help message
|
|
83
|
+
`.trim());
|
|
84
|
+
}
|
|
85
|
+
async function main() {
|
|
86
|
+
const [, , cmd, ...args] = process.argv;
|
|
87
|
+
void args;
|
|
88
|
+
switch (cmd) {
|
|
89
|
+
case "sync":
|
|
90
|
+
await cmdSync();
|
|
91
|
+
break;
|
|
92
|
+
case "prices":
|
|
93
|
+
cmdPrices();
|
|
94
|
+
break;
|
|
95
|
+
case "report":
|
|
96
|
+
console.log("report command requires SQLite storage to be configured in your app.");
|
|
97
|
+
break;
|
|
98
|
+
case "help":
|
|
99
|
+
case void 0:
|
|
100
|
+
cmdHelp();
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
console.error(`Unknown command: ${cmd}
|
|
104
|
+
Run "tokenwatch help" for usage.`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
main().catch((err) => {
|
|
109
|
+
console.error(err);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
});
|
|
112
|
+
//# sourceMappingURL=cli.cjs.map
|
package/dist/cli.cjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../bin/cli.ts","../src/core/sync.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { readFileSync } from 'node:fs'\nimport { join, dirname } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { fetchRemotePrices } from '../src/core/sync.js'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\n\nconst COMMANDS = ['sync', 'prices', 'report', 'help']\n\nfunction loadBundledPrices(): Record<string, { input: number; output: number }> {\n const pricesPath = join(__dirname, '..', 'prices.json')\n const raw = readFileSync(pricesPath, 'utf8')\n const data = JSON.parse(raw) as {\n updated_at: string\n models: Record<string, { input: number; output: number }>\n }\n return data.models\n}\n\nasync function cmdSync(): Promise<void> {\n console.log('Fetching latest prices from remote...')\n const result = await fetchRemotePrices()\n if (result) {\n console.log(`✓ Prices updated. ${Object.keys(result).length} models cached.`)\n } else {\n console.error('✗ Failed to fetch remote prices. Check your internet connection.')\n process.exit(1)\n }\n}\n\nfunction cmdPrices(): void {\n const models = loadBundledPrices()\n const rows = Object.entries(models).map(([name, price]) => ({\n model: name,\n input: `$${price.input.toFixed(2)}/M`,\n output: `$${price.output.toFixed(2)}/M`,\n }))\n\n const maxName = Math.max(...rows.map((r) => r.model.length), 5)\n const header = `${'Model'.padEnd(maxName)} ${'Input'.padStart(12)} ${'Output'.padStart(12)}`\n const sep = '-'.repeat(header.length)\n\n console.log(header)\n console.log(sep)\n for (const row of rows) {\n console.log(`${row.model.padEnd(maxName)} ${row.input.padStart(12)} ${row.output.padStart(12)}`)\n }\n}\n\nfunction cmdHelp(): void {\n console.log(`\ntokenwatch — CLI\n\nCommands:\n sync Fetch and cache latest model prices from remote\n prices List all bundled models and their current prices\n report Show last saved usage report (requires SQLite storage)\n help Show this help message\n`.trim())\n}\n\nasync function main(): Promise<void> {\n const [, , cmd, ...args] = process.argv\n void args\n\n switch (cmd) {\n case 'sync':\n await cmdSync()\n break\n case 'prices':\n cmdPrices()\n break\n case 'report':\n console.log('report command requires SQLite storage to be configured in your app.')\n break\n case 'help':\n case undefined:\n cmdHelp()\n break\n default:\n console.error(`Unknown command: ${cmd}\\nRun \"tokenwatch help\" for usage.`)\n process.exit(1)\n }\n}\n\nmain().catch((err: unknown) => {\n console.error(err)\n process.exit(1)\n})\n","import { readFile, writeFile, mkdir } from 'node:fs/promises'\nimport { existsSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\nimport type { PricesFile, PriceMap } from '../types/index.js'\n\nconst CACHE_DIR = join(homedir(), '.tokenwatch')\nconst CACHE_FILE = join(CACHE_DIR, 'prices.json')\nconst CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours\nconst REMOTE_URL =\n 'https://raw.githubusercontent.com/diogonzafe/tokenwatch/main/prices.json'\n\nexport async function fetchRemotePrices(url = REMOTE_URL): Promise<PriceMap | null> {\n try {\n const res = await fetch(url, { signal: AbortSignal.timeout(8_000) })\n if (!res.ok) return null\n const data = (await res.json()) as PricesFile\n if (!data?.models) return null\n await persistCache(data)\n return data.models\n } catch {\n return null\n }\n}\n\nexport async function loadCachedPrices(): Promise<PriceMap | null> {\n if (!existsSync(CACHE_FILE)) return null\n try {\n const raw = await readFile(CACHE_FILE, 'utf8')\n const data = JSON.parse(raw) as PricesFile & { _cachedAt?: number }\n const age = Date.now() - (data._cachedAt ?? 0)\n if (age > CACHE_TTL_MS) return null\n return data.models ?? null\n } catch {\n return null\n }\n}\n\nasync function persistCache(data: PricesFile): Promise<void> {\n try {\n await mkdir(CACHE_DIR, { recursive: true })\n const payload = { ...data, _cachedAt: Date.now() }\n await writeFile(CACHE_FILE, JSON.stringify(payload, null, 2), 'utf8')\n } catch {\n // best-effort — never throw\n }\n}\n\n/**\n * Returns the best available remote price map:\n * 1. Valid local cache (< 24h)\n * 2. Fresh remote fetch (also updates cache)\n * 3. null if both fail\n */\nexport async function getRemotePrices(): Promise<PriceMap | null> {\n const cached = await loadCachedPrices()\n if (cached) return cached\n return fetchRemotePrices()\n}\n"],"mappings":";;;;AACA,IAAAA,kBAA6B;AAC7B,IAAAC,oBAA8B;AAC9B,sBAA8B;;;ACH9B,sBAA2C;AAC3C,qBAA2B;AAC3B,qBAAwB;AACxB,uBAAqB;AAGrB,IAAM,gBAAY,2BAAK,wBAAQ,GAAG,aAAa;AAC/C,IAAM,iBAAa,uBAAK,WAAW,aAAa;AAChD,IAAM,eAAe,KAAK,KAAK,KAAK;AACpC,IAAM,aACJ;AAEF,eAAsB,kBAAkB,MAAM,YAAsC;AAClF,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,KAAK,EAAE,QAAQ,YAAY,QAAQ,GAAK,EAAE,CAAC;AACnE,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,QAAI,CAAC,MAAM,OAAQ,QAAO;AAC1B,UAAM,aAAa,IAAI;AACvB,WAAO,KAAK;AAAA,EACd,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAeA,eAAe,aAAa,MAAiC;AAC3D,MAAI;AACF,cAAM,uBAAM,WAAW,EAAE,WAAW,KAAK,CAAC;AAC1C,UAAM,UAAU,EAAE,GAAG,MAAM,WAAW,KAAK,IAAI,EAAE;AACjD,cAAM,2BAAU,YAAY,KAAK,UAAU,SAAS,MAAM,CAAC,GAAG,MAAM;AAAA,EACtE,QAAQ;AAAA,EAER;AACF;;;AD9CA;AAMA,IAAM,gBAAY,+BAAQ,+BAAc,YAAY,GAAG,CAAC;AAIxD,SAAS,oBAAuE;AAC9E,QAAM,iBAAa,wBAAK,WAAW,MAAM,aAAa;AACtD,QAAM,UAAM,8BAAa,YAAY,MAAM;AAC3C,QAAM,OAAO,KAAK,MAAM,GAAG;AAI3B,SAAO,KAAK;AACd;AAEA,eAAe,UAAyB;AACtC,UAAQ,IAAI,uCAAuC;AACnD,QAAM,SAAS,MAAM,kBAAkB;AACvC,MAAI,QAAQ;AACV,YAAQ,IAAI,0BAAqB,OAAO,KAAK,MAAM,EAAE,MAAM,iBAAiB;AAAA,EAC9E,OAAO;AACL,YAAQ,MAAM,uEAAkE;AAChF,YAAQ,KAAK,CAAC;AAAA,EAChB;AACF;AAEA,SAAS,YAAkB;AACzB,QAAM,SAAS,kBAAkB;AACjC,QAAM,OAAO,OAAO,QAAQ,MAAM,EAAE,IAAI,CAAC,CAAC,MAAM,KAAK,OAAO;AAAA,IAC1D,OAAO;AAAA,IACP,OAAO,IAAI,MAAM,MAAM,QAAQ,CAAC,CAAC;AAAA,IACjC,QAAQ,IAAI,MAAM,OAAO,QAAQ,CAAC,CAAC;AAAA,EACrC,EAAE;AAEF,QAAM,UAAU,KAAK,IAAI,GAAG,KAAK,IAAI,CAAC,MAAM,EAAE,MAAM,MAAM,GAAG,CAAC;AAC9D,QAAM,SAAS,GAAG,QAAQ,OAAO,OAAO,CAAC,KAAK,QAAQ,SAAS,EAAE,CAAC,KAAK,SAAS,SAAS,EAAE,CAAC;AAC5F,QAAM,MAAM,IAAI,OAAO,OAAO,MAAM;AAEpC,UAAQ,IAAI,MAAM;AAClB,UAAQ,IAAI,GAAG;AACf,aAAW,OAAO,MAAM;AACtB,YAAQ,IAAI,GAAG,IAAI,MAAM,OAAO,OAAO,CAAC,KAAK,IAAI,MAAM,SAAS,EAAE,CAAC,KAAK,IAAI,OAAO,SAAS,EAAE,CAAC,EAAE;AAAA,EACnG;AACF;AAEA,SAAS,UAAgB;AACvB,UAAQ,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQZ,KAAK,CAAC;AACR;AAEA,eAAe,OAAsB;AACnC,QAAM,CAAC,EAAE,EAAE,KAAK,GAAG,IAAI,IAAI,QAAQ;AACnC,OAAK;AAEL,UAAQ,KAAK;AAAA,IACX,KAAK;AACH,YAAM,QAAQ;AACd;AAAA,IACF,KAAK;AACH,gBAAU;AACV;AAAA,IACF,KAAK;AACH,cAAQ,IAAI,sEAAsE;AAClF;AAAA,IACF,KAAK;AAAA,IACL,KAAK;AACH,cAAQ;AACR;AAAA,IACF;AACE,cAAQ,MAAM,oBAAoB,GAAG;AAAA,iCAAoC;AACzE,cAAQ,KAAK,CAAC;AAAA,EAClB;AACF;AAEA,KAAK,EAAE,MAAM,CAAC,QAAiB;AAC7B,UAAQ,MAAM,GAAG;AACjB,UAAQ,KAAK,CAAC;AAChB,CAAC;","names":["import_node_fs","import_node_path"]}
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/cli.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { join as join2, dirname } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
|
|
8
|
+
// src/core/sync.ts
|
|
9
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
var CACHE_DIR = join(homedir(), ".tokenwatch");
|
|
14
|
+
var CACHE_FILE = join(CACHE_DIR, "prices.json");
|
|
15
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
16
|
+
var REMOTE_URL = "https://raw.githubusercontent.com/diogonzafe/tokenwatch/main/prices.json";
|
|
17
|
+
async function fetchRemotePrices(url = REMOTE_URL) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8e3) });
|
|
20
|
+
if (!res.ok) return null;
|
|
21
|
+
const data = await res.json();
|
|
22
|
+
if (!data?.models) return null;
|
|
23
|
+
await persistCache(data);
|
|
24
|
+
return data.models;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function persistCache(data) {
|
|
30
|
+
try {
|
|
31
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
32
|
+
const payload = { ...data, _cachedAt: Date.now() };
|
|
33
|
+
await writeFile(CACHE_FILE, JSON.stringify(payload, null, 2), "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// bin/cli.ts
|
|
39
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
function loadBundledPrices() {
|
|
41
|
+
const pricesPath = join2(__dirname, "..", "prices.json");
|
|
42
|
+
const raw = readFileSync(pricesPath, "utf8");
|
|
43
|
+
const data = JSON.parse(raw);
|
|
44
|
+
return data.models;
|
|
45
|
+
}
|
|
46
|
+
async function cmdSync() {
|
|
47
|
+
console.log("Fetching latest prices from remote...");
|
|
48
|
+
const result = await fetchRemotePrices();
|
|
49
|
+
if (result) {
|
|
50
|
+
console.log(`\u2713 Prices updated. ${Object.keys(result).length} models cached.`);
|
|
51
|
+
} else {
|
|
52
|
+
console.error("\u2717 Failed to fetch remote prices. Check your internet connection.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function cmdPrices() {
|
|
57
|
+
const models = loadBundledPrices();
|
|
58
|
+
const rows = Object.entries(models).map(([name, price]) => ({
|
|
59
|
+
model: name,
|
|
60
|
+
input: `$${price.input.toFixed(2)}/M`,
|
|
61
|
+
output: `$${price.output.toFixed(2)}/M`
|
|
62
|
+
}));
|
|
63
|
+
const maxName = Math.max(...rows.map((r) => r.model.length), 5);
|
|
64
|
+
const header = `${"Model".padEnd(maxName)} ${"Input".padStart(12)} ${"Output".padStart(12)}`;
|
|
65
|
+
const sep = "-".repeat(header.length);
|
|
66
|
+
console.log(header);
|
|
67
|
+
console.log(sep);
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
console.log(`${row.model.padEnd(maxName)} ${row.input.padStart(12)} ${row.output.padStart(12)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function cmdHelp() {
|
|
73
|
+
console.log(`
|
|
74
|
+
tokenwatch \u2014 CLI
|
|
75
|
+
|
|
76
|
+
Commands:
|
|
77
|
+
sync Fetch and cache latest model prices from remote
|
|
78
|
+
prices List all bundled models and their current prices
|
|
79
|
+
report Show last saved usage report (requires SQLite storage)
|
|
80
|
+
help Show this help message
|
|
81
|
+
`.trim());
|
|
82
|
+
}
|
|
83
|
+
async function main() {
|
|
84
|
+
const [, , cmd, ...args] = process.argv;
|
|
85
|
+
void args;
|
|
86
|
+
switch (cmd) {
|
|
87
|
+
case "sync":
|
|
88
|
+
await cmdSync();
|
|
89
|
+
break;
|
|
90
|
+
case "prices":
|
|
91
|
+
cmdPrices();
|
|
92
|
+
break;
|
|
93
|
+
case "report":
|
|
94
|
+
console.log("report command requires SQLite storage to be configured in your app.");
|
|
95
|
+
break;
|
|
96
|
+
case "help":
|
|
97
|
+
case void 0:
|
|
98
|
+
cmdHelp();
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
console.error(`Unknown command: ${cmd}
|
|
102
|
+
Run "tokenwatch help" for usage.`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
main().catch((err) => {
|
|
107
|
+
console.error(err);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|
|
110
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../bin/cli.ts","../src/core/sync.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { readFileSync } from 'node:fs'\nimport { join, dirname } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { fetchRemotePrices } from '../src/core/sync.js'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\n\nconst COMMANDS = ['sync', 'prices', 'report', 'help']\n\nfunction loadBundledPrices(): Record<string, { input: number; output: number }> {\n const pricesPath = join(__dirname, '..', 'prices.json')\n const raw = readFileSync(pricesPath, 'utf8')\n const data = JSON.parse(raw) as {\n updated_at: string\n models: Record<string, { input: number; output: number }>\n }\n return data.models\n}\n\nasync function cmdSync(): Promise<void> {\n console.log('Fetching latest prices from remote...')\n const result = await fetchRemotePrices()\n if (result) {\n console.log(`✓ Prices updated. ${Object.keys(result).length} models cached.`)\n } else {\n console.error('✗ Failed to fetch remote prices. Check your internet connection.')\n process.exit(1)\n }\n}\n\nfunction cmdPrices(): void {\n const models = loadBundledPrices()\n const rows = Object.entries(models).map(([name, price]) => ({\n model: name,\n input: `$${price.input.toFixed(2)}/M`,\n output: `$${price.output.toFixed(2)}/M`,\n }))\n\n const maxName = Math.max(...rows.map((r) => r.model.length), 5)\n const header = `${'Model'.padEnd(maxName)} ${'Input'.padStart(12)} ${'Output'.padStart(12)}`\n const sep = '-'.repeat(header.length)\n\n console.log(header)\n console.log(sep)\n for (const row of rows) {\n console.log(`${row.model.padEnd(maxName)} ${row.input.padStart(12)} ${row.output.padStart(12)}`)\n }\n}\n\nfunction cmdHelp(): void {\n console.log(`\ntokenwatch — CLI\n\nCommands:\n sync Fetch and cache latest model prices from remote\n prices List all bundled models and their current prices\n report Show last saved usage report (requires SQLite storage)\n help Show this help message\n`.trim())\n}\n\nasync function main(): Promise<void> {\n const [, , cmd, ...args] = process.argv\n void args\n\n switch (cmd) {\n case 'sync':\n await cmdSync()\n break\n case 'prices':\n cmdPrices()\n break\n case 'report':\n console.log('report command requires SQLite storage to be configured in your app.')\n break\n case 'help':\n case undefined:\n cmdHelp()\n break\n default:\n console.error(`Unknown command: ${cmd}\\nRun \"tokenwatch help\" for usage.`)\n process.exit(1)\n }\n}\n\nmain().catch((err: unknown) => {\n console.error(err)\n process.exit(1)\n})\n","import { readFile, writeFile, mkdir } from 'node:fs/promises'\nimport { existsSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\nimport type { PricesFile, PriceMap } from '../types/index.js'\n\nconst CACHE_DIR = join(homedir(), '.tokenwatch')\nconst CACHE_FILE = join(CACHE_DIR, 'prices.json')\nconst CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours\nconst REMOTE_URL =\n 'https://raw.githubusercontent.com/diogonzafe/tokenwatch/main/prices.json'\n\nexport async function fetchRemotePrices(url = REMOTE_URL): Promise<PriceMap | null> {\n try {\n const res = await fetch(url, { signal: AbortSignal.timeout(8_000) })\n if (!res.ok) return null\n const data = (await res.json()) as PricesFile\n if (!data?.models) return null\n await persistCache(data)\n return data.models\n } catch {\n return null\n }\n}\n\nexport async function loadCachedPrices(): Promise<PriceMap | null> {\n if (!existsSync(CACHE_FILE)) return null\n try {\n const raw = await readFile(CACHE_FILE, 'utf8')\n const data = JSON.parse(raw) as PricesFile & { _cachedAt?: number }\n const age = Date.now() - (data._cachedAt ?? 0)\n if (age > CACHE_TTL_MS) return null\n return data.models ?? null\n } catch {\n return null\n }\n}\n\nasync function persistCache(data: PricesFile): Promise<void> {\n try {\n await mkdir(CACHE_DIR, { recursive: true })\n const payload = { ...data, _cachedAt: Date.now() }\n await writeFile(CACHE_FILE, JSON.stringify(payload, null, 2), 'utf8')\n } catch {\n // best-effort — never throw\n }\n}\n\n/**\n * Returns the best available remote price map:\n * 1. Valid local cache (< 24h)\n * 2. Fresh remote fetch (also updates cache)\n * 3. null if both fail\n */\nexport async function getRemotePrices(): Promise<PriceMap | null> {\n const cached = await loadCachedPrices()\n if (cached) return cached\n return fetchRemotePrices()\n}\n"],"mappings":"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","names":["join","join"]}
|