@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 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
@@ -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":["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
@@ -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,SAAS,oBAAoB;AAC7B,SAAS,QAAAA,OAAM,eAAe;AAC9B,SAAS,qBAAqB;;;ACH9B,SAAS,UAAU,WAAW,aAAa;AAC3C,SAAS,kBAAkB;AAC3B,SAAS,eAAe;AACxB,SAAS,YAAY;AAGrB,IAAM,YAAY,KAAK,QAAQ,GAAG,aAAa;AAC/C,IAAM,aAAa,KAAK,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,UAAM,MAAM,WAAW,EAAE,WAAW,KAAK,CAAC;AAC1C,UAAM,UAAU,EAAE,GAAG,MAAM,WAAW,KAAK,IAAI,EAAE;AACjD,UAAM,UAAU,YAAY,KAAK,UAAU,SAAS,MAAM,CAAC,GAAG,MAAM;AAAA,EACtE,QAAQ;AAAA,EAER;AACF;;;ADxCA,IAAM,YAAY,QAAQ,cAAc,YAAY,GAAG,CAAC;AAIxD,SAAS,oBAAuE;AAC9E,QAAM,aAAaC,MAAK,WAAW,MAAM,aAAa;AACtD,QAAM,MAAM,aAAa,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":["join","join"]}