opencode-token-tracker 1.1.0 → 1.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 +89 -5
- package/dist/bin/opencode-tokens.js +257 -28
- package/dist/index.js +80 -26
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -94,6 +94,36 @@ Breakdown options (`--by`):
|
|
|
94
94
|
- `daily` - Show day-by-day breakdown
|
|
95
95
|
- `all` - Show all breakdowns
|
|
96
96
|
|
|
97
|
+
### Pricing & Config Commands
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Show built-in pricing table
|
|
101
|
+
opencode-tokens pricing
|
|
102
|
+
|
|
103
|
+
# Show your used models and their pricing status
|
|
104
|
+
opencode-tokens models
|
|
105
|
+
|
|
106
|
+
# Show current config
|
|
107
|
+
opencode-tokens config
|
|
108
|
+
|
|
109
|
+
# Generate example config based on your usage
|
|
110
|
+
opencode-tokens config init
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Example `models` output:
|
|
114
|
+
```
|
|
115
|
+
Model Provider Msgs Pricing
|
|
116
|
+
------------------------ ---------------- -------- ------------
|
|
117
|
+
claude-opus-4.5 github-copilot 379 provider cfg
|
|
118
|
+
deepseek-chat deepseek 6 built-in
|
|
119
|
+
gpt-5.2 openai 18 built-in
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This helps you understand:
|
|
123
|
+
- Which models/providers you're using
|
|
124
|
+
- Whether pricing is from built-in table, your config, or default fallback
|
|
125
|
+
- What to add to your config file
|
|
126
|
+
|
|
97
127
|
## Log Files
|
|
98
128
|
|
|
99
129
|
Token usage is logged to:
|
|
@@ -135,12 +165,66 @@ Unknown models use a default pricing estimate.
|
|
|
135
165
|
|
|
136
166
|
## Configuration
|
|
137
167
|
|
|
138
|
-
|
|
168
|
+
Create a config file at `~/.config/opencode/token-tracker.json`:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"providers": {
|
|
173
|
+
"github-copilot": { "input": 0, "output": 0 }
|
|
174
|
+
},
|
|
175
|
+
"models": {
|
|
176
|
+
"my-custom-model": { "input": 1, "output": 2 }
|
|
177
|
+
},
|
|
178
|
+
"toast": {
|
|
179
|
+
"enabled": true,
|
|
180
|
+
"duration": 3000,
|
|
181
|
+
"showOnIdle": true
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Pricing Override
|
|
187
|
+
|
|
188
|
+
Pricing is resolved in this order (first match wins):
|
|
189
|
+
|
|
190
|
+
1. **Provider-level** - Override all models for a provider
|
|
191
|
+
2. **User model config** - Custom model pricing in config file
|
|
192
|
+
3. **Built-in pricing** - Default pricing table
|
|
193
|
+
4. **Fallback** - $1/M input, $4/M output
|
|
194
|
+
|
|
195
|
+
#### Example: Free providers
|
|
196
|
+
|
|
197
|
+
If you're using GitHub Copilot or other subscription-based services, set their cost to $0:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{
|
|
201
|
+
"providers": {
|
|
202
|
+
"github-copilot": { "input": 0, "output": 0 },
|
|
203
|
+
"cursor": { "input": 0, "output": 0 }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### Example: Custom model pricing
|
|
209
|
+
|
|
210
|
+
Override or add pricing for specific models:
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"models": {
|
|
215
|
+
"claude-opus-4.5": { "input": 12, "output": 60, "cacheRead": 1.2 },
|
|
216
|
+
"my-local-model": { "input": 0, "output": 0 }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Toast Settings
|
|
139
222
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
223
|
+
| Option | Type | Default | Description |
|
|
224
|
+
|--------|------|---------|-------------|
|
|
225
|
+
| `enabled` | boolean | `true` | Show toast notifications |
|
|
226
|
+
| `duration` | number | `3000` | Toast display duration (ms) |
|
|
227
|
+
| `showOnIdle` | boolean | `true` | Show session summary on idle |
|
|
144
228
|
|
|
145
229
|
## Development
|
|
146
230
|
|
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
-
const
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
|
|
7
|
+
const LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "tokens.jsonl");
|
|
8
|
+
const BUILTIN_PRICING = {
|
|
9
|
+
// Anthropic Claude
|
|
10
|
+
"claude-opus-4.5": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
11
|
+
"claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
12
|
+
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
13
|
+
"claude-haiku-4.5": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
14
|
+
"claude-haiku-4": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
15
|
+
// OpenAI GPT
|
|
16
|
+
"gpt-5.2": { input: 2.5, output: 10 },
|
|
17
|
+
"gpt-5.2-codex": { input: 3, output: 12 },
|
|
18
|
+
"gpt-5.1": { input: 2, output: 8 },
|
|
19
|
+
"gpt-5": { input: 5, output: 15 },
|
|
20
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
21
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6 },
|
|
22
|
+
"gpt-4.1-nano": { input: 0.1, output: 0.4 },
|
|
23
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
24
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
25
|
+
"o3": { input: 10, output: 40 },
|
|
26
|
+
"o3-mini": { input: 1.1, output: 4.4 },
|
|
27
|
+
"o1": { input: 15, output: 60 },
|
|
28
|
+
"o1-mini": { input: 1.1, output: 4.4 },
|
|
29
|
+
// DeepSeek
|
|
30
|
+
"deepseek-chat": { input: 0.14, output: 0.28, cacheRead: 0.014 },
|
|
31
|
+
"deepseek-reasoner": { input: 0.55, output: 2.19, cacheRead: 0.055 },
|
|
32
|
+
// Google Gemini
|
|
33
|
+
"gemini-3-pro": { input: 1.25, output: 5 },
|
|
34
|
+
"gemini-3-pro-preview": { input: 1.25, output: 5 },
|
|
35
|
+
"gemini-3-flash": { input: 0.1, output: 0.4 },
|
|
36
|
+
"gemini-2.5-pro": { input: 1.25, output: 5 },
|
|
37
|
+
"gemini-2.5-flash": { input: 0.075, output: 0.3 },
|
|
38
|
+
"gemini-2.0-flash": { input: 0.1, output: 0.4 },
|
|
39
|
+
// Fallback
|
|
40
|
+
"_default": { input: 1, output: 4 },
|
|
41
|
+
};
|
|
6
42
|
// ============================================================================
|
|
7
43
|
// Helpers
|
|
8
44
|
// ============================================================================
|
|
@@ -34,7 +70,7 @@ function getStartOfDay(date) {
|
|
|
34
70
|
function getStartOfWeek(date) {
|
|
35
71
|
const d = new Date(date);
|
|
36
72
|
const day = d.getDay();
|
|
37
|
-
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
73
|
+
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
38
74
|
d.setDate(diff);
|
|
39
75
|
d.setHours(0, 0, 0, 0);
|
|
40
76
|
return d.getTime();
|
|
@@ -63,7 +99,7 @@ function loadEntries(since) {
|
|
|
63
99
|
if (since && entry._ts < since)
|
|
64
100
|
continue;
|
|
65
101
|
if (!entry.input && !entry.output)
|
|
66
|
-
continue;
|
|
102
|
+
continue;
|
|
67
103
|
entries.push(entry);
|
|
68
104
|
}
|
|
69
105
|
catch {
|
|
@@ -72,6 +108,15 @@ function loadEntries(since) {
|
|
|
72
108
|
}
|
|
73
109
|
return entries;
|
|
74
110
|
}
|
|
111
|
+
function loadConfig() {
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(CONFIG_FILE)) {
|
|
114
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
75
120
|
// ============================================================================
|
|
76
121
|
// Stats Aggregation
|
|
77
122
|
// ============================================================================
|
|
@@ -129,13 +174,11 @@ function printSummary(title, stats) {
|
|
|
129
174
|
console.log();
|
|
130
175
|
}
|
|
131
176
|
function printTable(title, groups, labelHeader) {
|
|
132
|
-
// Sort by cost descending
|
|
133
177
|
const sorted = Array.from(groups.entries()).sort((a, b) => b[1].cost - a[1].cost);
|
|
134
178
|
if (sorted.length === 0) {
|
|
135
179
|
console.log(`\n No data for ${title}\n`);
|
|
136
180
|
return;
|
|
137
181
|
}
|
|
138
|
-
// Calculate column widths
|
|
139
182
|
const labelWidth = Math.max(labelHeader.length, ...sorted.map(([k]) => k.length));
|
|
140
183
|
const tokensWidth = 10;
|
|
141
184
|
const costWidth = 10;
|
|
@@ -143,10 +186,8 @@ function printTable(title, groups, labelHeader) {
|
|
|
143
186
|
console.log();
|
|
144
187
|
console.log(` ${title}`);
|
|
145
188
|
console.log(` ${"─".repeat(labelWidth + tokensWidth + costWidth + countWidth + 12)}`);
|
|
146
|
-
// Header
|
|
147
189
|
console.log(` ${padRight(labelHeader, labelWidth)} ${padLeft("Tokens", tokensWidth)} ${padLeft("Cost", costWidth)} ${padLeft("Msgs", countWidth)}`);
|
|
148
190
|
console.log(` ${"-".repeat(labelWidth)} ${"-".repeat(tokensWidth)} ${"-".repeat(costWidth)} ${"-".repeat(countWidth)}`);
|
|
149
|
-
// Rows
|
|
150
191
|
for (const [label, stats] of sorted) {
|
|
151
192
|
const totalTokens = stats.input + stats.output;
|
|
152
193
|
console.log(` ${padRight(label, labelWidth)} ${padLeft(formatTokens(totalTokens), tokensWidth)} ${padLeft(formatCost(stats.cost), costWidth)} ${padLeft(stats.count.toString(), countWidth)}`);
|
|
@@ -156,9 +197,8 @@ function printTable(title, groups, labelHeader) {
|
|
|
156
197
|
function printDailyBreakdown(entries) {
|
|
157
198
|
const byDay = groupBy(entries, (e) => {
|
|
158
199
|
const date = new Date(e._ts);
|
|
159
|
-
return date.toISOString().slice(0, 10);
|
|
200
|
+
return date.toISOString().slice(0, 10);
|
|
160
201
|
});
|
|
161
|
-
// Sort by date descending
|
|
162
202
|
const sorted = Array.from(byDay.entries()).sort((a, b) => b[0].localeCompare(a[0]));
|
|
163
203
|
if (sorted.length === 0) {
|
|
164
204
|
console.log("\n No data\n");
|
|
@@ -210,10 +250,8 @@ function cmdStats(period, breakdown) {
|
|
|
210
250
|
console.log(`\n No data for ${title.toLowerCase()}\n`);
|
|
211
251
|
return;
|
|
212
252
|
}
|
|
213
|
-
// Overall summary
|
|
214
253
|
const total = aggregateStats(entries);
|
|
215
254
|
printSummary(title, total);
|
|
216
|
-
// Breakdown
|
|
217
255
|
switch (breakdown) {
|
|
218
256
|
case "model":
|
|
219
257
|
printTable("By Model", groupBy(entries, (e) => e.model ?? "unknown"), "Model");
|
|
@@ -235,32 +273,209 @@ function cmdStats(period, breakdown) {
|
|
|
235
273
|
break;
|
|
236
274
|
}
|
|
237
275
|
}
|
|
276
|
+
function cmdPricing() {
|
|
277
|
+
const config = loadConfig();
|
|
278
|
+
console.log(`
|
|
279
|
+
Built-in Pricing Table (USD per 1M tokens)
|
|
280
|
+
══════════════════════════════════════════════════════════════════
|
|
281
|
+
`);
|
|
282
|
+
// Group by provider
|
|
283
|
+
const groups = {
|
|
284
|
+
"Anthropic Claude": ["claude-opus-4.5", "claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5", "claude-haiku-4"],
|
|
285
|
+
"OpenAI": ["gpt-5.2", "gpt-5.2-codex", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", "o3", "o3-mini", "o1", "o1-mini"],
|
|
286
|
+
"DeepSeek": ["deepseek-chat", "deepseek-reasoner"],
|
|
287
|
+
"Google Gemini": ["gemini-3-pro", "gemini-3-pro-preview", "gemini-3-flash", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
|
|
288
|
+
};
|
|
289
|
+
const modelWidth = 20;
|
|
290
|
+
const priceWidth = 10;
|
|
291
|
+
for (const [group, models] of Object.entries(groups)) {
|
|
292
|
+
console.log(` ${group}`);
|
|
293
|
+
console.log(` ${"-".repeat(modelWidth + priceWidth * 4 + 12)}`);
|
|
294
|
+
console.log(` ${padRight("Model", modelWidth)} ${padLeft("Input", priceWidth)} ${padLeft("Output", priceWidth)} ${padLeft("CacheRd", priceWidth)} ${padLeft("CacheWr", priceWidth)}`);
|
|
295
|
+
for (const model of models) {
|
|
296
|
+
const p = BUILTIN_PRICING[model];
|
|
297
|
+
if (!p)
|
|
298
|
+
continue;
|
|
299
|
+
const overridden = config.models?.[model] ? " *" : "";
|
|
300
|
+
console.log(` ${padRight(model + overridden, modelWidth)} ${padLeft("$" + p.input.toString(), priceWidth)} ${padLeft("$" + p.output.toString(), priceWidth)} ${padLeft(p.cacheRead ? "$" + p.cacheRead.toString() : "-", priceWidth)} ${padLeft(p.cacheWrite ? "$" + p.cacheWrite.toString() : "-", priceWidth)}`);
|
|
301
|
+
}
|
|
302
|
+
console.log();
|
|
303
|
+
}
|
|
304
|
+
console.log(` Default (unknown models)`);
|
|
305
|
+
console.log(` ${"-".repeat(modelWidth + priceWidth * 4 + 12)}`);
|
|
306
|
+
const def = BUILTIN_PRICING["_default"];
|
|
307
|
+
console.log(` ${padRight("_default", modelWidth)} ${padLeft("$" + def.input.toString(), priceWidth)} ${padLeft("$" + def.output.toString(), priceWidth)} ${padLeft("-", priceWidth)} ${padLeft("-", priceWidth)}`);
|
|
308
|
+
console.log();
|
|
309
|
+
if (Object.keys(config.models || {}).length > 0) {
|
|
310
|
+
console.log(` * = overridden in config`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function cmdModels() {
|
|
314
|
+
const entries = loadEntries();
|
|
315
|
+
if (entries.length === 0) {
|
|
316
|
+
console.log(`\n No usage data found. Start using OpenCode to collect data.\n`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Get unique model+provider combinations
|
|
320
|
+
const modelProviders = new Map();
|
|
321
|
+
for (const e of entries) {
|
|
322
|
+
const model = e.model ?? "unknown";
|
|
323
|
+
const provider = e.provider ?? "unknown";
|
|
324
|
+
const key = `${model}|${provider}`;
|
|
325
|
+
if (!modelProviders.has(key)) {
|
|
326
|
+
modelProviders.set(key, { provider, count: 0, lastUsed: 0 });
|
|
327
|
+
}
|
|
328
|
+
const info = modelProviders.get(key);
|
|
329
|
+
info.count++;
|
|
330
|
+
info.lastUsed = Math.max(info.lastUsed, e._ts);
|
|
331
|
+
}
|
|
332
|
+
// Sort by last used
|
|
333
|
+
const sorted = Array.from(modelProviders.entries())
|
|
334
|
+
.map(([key, info]) => ({ model: key.split("|")[0], ...info }))
|
|
335
|
+
.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
336
|
+
const config = loadConfig();
|
|
337
|
+
console.log(`
|
|
338
|
+
Your Used Models
|
|
339
|
+
══════════════════════════════════════════════════════════════════
|
|
340
|
+
`);
|
|
341
|
+
const modelWidth = 24;
|
|
342
|
+
const providerWidth = 16;
|
|
343
|
+
const countWidth = 8;
|
|
344
|
+
const statusWidth = 12;
|
|
345
|
+
console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)}`);
|
|
346
|
+
console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
|
|
347
|
+
for (const { model, provider, count } of sorted) {
|
|
348
|
+
let status = "built-in";
|
|
349
|
+
if (config.providers?.[provider]) {
|
|
350
|
+
status = "provider cfg";
|
|
351
|
+
}
|
|
352
|
+
else if (config.models?.[model]) {
|
|
353
|
+
status = "model cfg";
|
|
354
|
+
}
|
|
355
|
+
else if (!BUILTIN_PRICING[model]) {
|
|
356
|
+
// Check partial match
|
|
357
|
+
const hasMatch = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && model.toLowerCase().includes(k.toLowerCase()));
|
|
358
|
+
status = hasMatch ? "built-in" : "default";
|
|
359
|
+
}
|
|
360
|
+
console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)}`);
|
|
361
|
+
}
|
|
362
|
+
console.log();
|
|
363
|
+
console.log(` Pricing status:`);
|
|
364
|
+
console.log(` built-in = using built-in pricing table`);
|
|
365
|
+
console.log(` provider cfg = overridden by providers config`);
|
|
366
|
+
console.log(` model cfg = overridden by models config`);
|
|
367
|
+
console.log(` default = unknown model, using $1/$4 per 1M tokens`);
|
|
368
|
+
console.log();
|
|
369
|
+
}
|
|
370
|
+
function cmdConfig(action) {
|
|
371
|
+
const config = loadConfig();
|
|
372
|
+
const entries = loadEntries();
|
|
373
|
+
if (action === "init" || action === "generate") {
|
|
374
|
+
// Get unique providers from logs
|
|
375
|
+
const providers = new Set();
|
|
376
|
+
const models = new Set();
|
|
377
|
+
for (const e of entries) {
|
|
378
|
+
if (e.provider)
|
|
379
|
+
providers.add(e.provider);
|
|
380
|
+
if (e.model)
|
|
381
|
+
models.add(e.model);
|
|
382
|
+
}
|
|
383
|
+
// Find providers/models without built-in pricing
|
|
384
|
+
const unknownModels = Array.from(models).filter(m => {
|
|
385
|
+
if (BUILTIN_PRICING[m])
|
|
386
|
+
return false;
|
|
387
|
+
const hasMatch = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && m.toLowerCase().includes(k.toLowerCase()));
|
|
388
|
+
return !hasMatch;
|
|
389
|
+
});
|
|
390
|
+
const exampleConfig = {
|
|
391
|
+
providers: {},
|
|
392
|
+
models: {},
|
|
393
|
+
toast: {
|
|
394
|
+
enabled: true,
|
|
395
|
+
duration: 3000,
|
|
396
|
+
showOnIdle: true,
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
// Add providers as comments/examples
|
|
400
|
+
for (const provider of providers) {
|
|
401
|
+
// Common free providers
|
|
402
|
+
if (provider.includes("copilot") || provider.includes("cursor") || provider.includes("free")) {
|
|
403
|
+
exampleConfig.providers[provider] = { input: 0, output: 0 };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Add unknown models
|
|
407
|
+
for (const model of unknownModels) {
|
|
408
|
+
exampleConfig.models[model] = { input: 1, output: 4 };
|
|
409
|
+
}
|
|
410
|
+
if (action === "generate") {
|
|
411
|
+
const json = JSON.stringify(exampleConfig, null, 2);
|
|
412
|
+
writeFileSync(CONFIG_FILE, json);
|
|
413
|
+
console.log(`\n Config file generated: ${CONFIG_FILE}\n`);
|
|
414
|
+
console.log(json);
|
|
415
|
+
console.log();
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
console.log(`
|
|
419
|
+
Example config based on your usage:
|
|
420
|
+
────────────────────────────────────────────────────────────────
|
|
421
|
+
`);
|
|
422
|
+
console.log(JSON.stringify(exampleConfig, null, 2));
|
|
423
|
+
console.log(`
|
|
424
|
+
To create this config file, run:
|
|
425
|
+
opencode-tokens config generate
|
|
426
|
+
|
|
427
|
+
Or manually create: ${CONFIG_FILE}
|
|
428
|
+
`);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// Show current config
|
|
433
|
+
console.log(`
|
|
434
|
+
Current Configuration
|
|
435
|
+
══════════════════════════════════════════════════════════════════
|
|
436
|
+
|
|
437
|
+
Config file: ${CONFIG_FILE}
|
|
438
|
+
Status: ${existsSync(CONFIG_FILE) ? "exists" : "not found (using defaults)"}
|
|
439
|
+
`);
|
|
440
|
+
if (existsSync(CONFIG_FILE)) {
|
|
441
|
+
console.log(` Contents:`);
|
|
442
|
+
console.log(` ${"-".repeat(60)}`);
|
|
443
|
+
console.log(JSON.stringify(config, null, 2).split("\n").map(l => " " + l).join("\n"));
|
|
444
|
+
console.log();
|
|
445
|
+
}
|
|
446
|
+
console.log(` Commands:`);
|
|
447
|
+
console.log(` opencode-tokens config init Show example config based on your usage`);
|
|
448
|
+
console.log(` opencode-tokens config generate Create config file with example`);
|
|
449
|
+
console.log();
|
|
450
|
+
}
|
|
238
451
|
function cmdHelp() {
|
|
239
452
|
console.log(`
|
|
240
453
|
opencode-tokens - Token usage statistics CLI
|
|
241
454
|
|
|
242
455
|
Usage:
|
|
243
|
-
opencode-tokens [
|
|
456
|
+
opencode-tokens [command] [options]
|
|
244
457
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
458
|
+
Commands:
|
|
459
|
+
(default) Show usage statistics
|
|
460
|
+
pricing Show built-in pricing table
|
|
461
|
+
models Show your used models and their pricing status
|
|
462
|
+
config Show/generate configuration
|
|
250
463
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
464
|
+
Statistics Options:
|
|
465
|
+
today Show today's usage
|
|
466
|
+
week Show this week's usage
|
|
467
|
+
month Show this month's usage
|
|
468
|
+
all Show all-time usage (default)
|
|
469
|
+
|
|
470
|
+
--by <type> Group by: model, agent, provider, daily, all
|
|
257
471
|
|
|
258
472
|
Examples:
|
|
259
473
|
opencode-tokens # All-time summary
|
|
260
474
|
opencode-tokens today # Today's summary
|
|
261
|
-
opencode-tokens week --by model # This week,
|
|
262
|
-
opencode-tokens
|
|
263
|
-
opencode-tokens
|
|
475
|
+
opencode-tokens week --by model # This week, by model
|
|
476
|
+
opencode-tokens pricing # Show pricing table
|
|
477
|
+
opencode-tokens models # Show your models
|
|
478
|
+
opencode-tokens config init # Generate example config
|
|
264
479
|
`);
|
|
265
480
|
}
|
|
266
481
|
// ============================================================================
|
|
@@ -268,11 +483,25 @@ function cmdHelp() {
|
|
|
268
483
|
// ============================================================================
|
|
269
484
|
function main() {
|
|
270
485
|
const args = process.argv.slice(2);
|
|
486
|
+
const command = args[0];
|
|
271
487
|
if (args.includes("--help") || args.includes("-h")) {
|
|
272
488
|
cmdHelp();
|
|
273
489
|
return;
|
|
274
490
|
}
|
|
275
|
-
//
|
|
491
|
+
// Handle subcommands
|
|
492
|
+
if (command === "pricing") {
|
|
493
|
+
cmdPricing();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (command === "models") {
|
|
497
|
+
cmdModels();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (command === "config") {
|
|
501
|
+
cmdConfig(args[1]);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// Parse stats arguments
|
|
276
505
|
let period = "all";
|
|
277
506
|
let breakdown;
|
|
278
507
|
for (let i = 0; i < args.length; i++) {
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync } from "fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
-
const
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
5
|
+
const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
|
|
6
|
+
const LOG_DIR = join(CONFIG_DIR, "logs", "token-tracker");
|
|
5
7
|
const LOG_FILE = join(LOG_DIR, "tokens.jsonl");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
providers: {},
|
|
10
|
+
models: {},
|
|
11
|
+
toast: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
duration: 3000,
|
|
14
|
+
showOnIdle: true,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
// Built-in pricing table (USD per 1M tokens) - as of 2026-02
|
|
18
|
+
const BUILTIN_PRICING = {
|
|
8
19
|
// Anthropic Claude
|
|
9
20
|
"claude-opus-4.5": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
10
21
|
"claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
@@ -38,21 +49,58 @@ const PRICING = {
|
|
|
38
49
|
// Fallback for unknown models
|
|
39
50
|
"_default": { input: 1, output: 4 },
|
|
40
51
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
let config = DEFAULT_CONFIG;
|
|
53
|
+
function loadConfig() {
|
|
54
|
+
try {
|
|
55
|
+
if (existsSync(CONFIG_FILE)) {
|
|
56
|
+
const content = readFileSync(CONFIG_FILE, "utf-8");
|
|
57
|
+
const userConfig = JSON.parse(content);
|
|
58
|
+
return {
|
|
59
|
+
providers: { ...DEFAULT_CONFIG.providers, ...userConfig.providers },
|
|
60
|
+
models: { ...DEFAULT_CONFIG.models, ...userConfig.models },
|
|
61
|
+
toast: { ...DEFAULT_CONFIG.toast, ...userConfig.toast },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
// Config parse error - use defaults
|
|
67
|
+
}
|
|
68
|
+
return DEFAULT_CONFIG;
|
|
69
|
+
}
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Pricing
|
|
72
|
+
// ============================================================================
|
|
73
|
+
function getModelPricing(model, provider) {
|
|
74
|
+
// 1. Check provider-level override first (highest priority)
|
|
75
|
+
if (config.providers[provider]) {
|
|
76
|
+
return config.providers[provider];
|
|
77
|
+
}
|
|
78
|
+
// 2. Check user-defined model pricing
|
|
79
|
+
if (config.models[model]) {
|
|
80
|
+
return config.models[model];
|
|
81
|
+
}
|
|
82
|
+
// 3. Check built-in exact match
|
|
83
|
+
if (BUILTIN_PRICING[model]) {
|
|
84
|
+
return BUILTIN_PRICING[model];
|
|
85
|
+
}
|
|
86
|
+
// 4. Try partial match in user config
|
|
46
87
|
const modelLower = model.toLowerCase();
|
|
47
|
-
for (const [key, pricing] of Object.entries(
|
|
88
|
+
for (const [key, pricing] of Object.entries(config.models)) {
|
|
89
|
+
if (modelLower.includes(key.toLowerCase())) {
|
|
90
|
+
return pricing;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// 5. Try partial match in built-in pricing
|
|
94
|
+
for (const [key, pricing] of Object.entries(BUILTIN_PRICING)) {
|
|
48
95
|
if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
|
|
49
96
|
return pricing;
|
|
50
97
|
}
|
|
51
98
|
}
|
|
52
|
-
|
|
99
|
+
// 6. Fallback to default
|
|
100
|
+
return BUILTIN_PRICING["_default"];
|
|
53
101
|
}
|
|
54
|
-
function calculateCost(model, input, output, cacheRead = 0, cacheWrite = 0) {
|
|
55
|
-
const pricing = getModelPricing(model);
|
|
102
|
+
function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
|
|
103
|
+
const pricing = getModelPricing(model, provider);
|
|
56
104
|
// Billable input = total input - cache read (cached tokens are charged at cache rate)
|
|
57
105
|
const billableInput = Math.max(0, input - cacheRead);
|
|
58
106
|
const inputCost = (billableInput / 1_000_000) * pricing.input;
|
|
@@ -120,7 +168,9 @@ function logJson(data) {
|
|
|
120
168
|
appendFileSync(LOG_FILE, entry);
|
|
121
169
|
}
|
|
122
170
|
export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
123
|
-
|
|
171
|
+
// Load config on plugin init
|
|
172
|
+
config = loadConfig();
|
|
173
|
+
logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
|
|
124
174
|
return {
|
|
125
175
|
event: async ({ event }) => {
|
|
126
176
|
try {
|
|
@@ -147,7 +197,7 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
147
197
|
return;
|
|
148
198
|
const model = info.model?.modelID ?? info.modelID ?? "unknown";
|
|
149
199
|
const provider = info.model?.providerID ?? info.providerID ?? "unknown";
|
|
150
|
-
const cost = calculateCost(model, input, output, cacheRead, cacheWrite);
|
|
200
|
+
const cost = calculateCost(model, provider, input, output, cacheRead, cacheWrite);
|
|
151
201
|
// Update session stats
|
|
152
202
|
const stats = getOrCreateSessionStats(sessionId);
|
|
153
203
|
stats.totalInput += input;
|
|
@@ -174,21 +224,25 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
174
224
|
cost,
|
|
175
225
|
});
|
|
176
226
|
// Show toast for this message
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
if (config.toast.enabled) {
|
|
228
|
+
const totalTokens = input + output;
|
|
229
|
+
try {
|
|
230
|
+
await client.tui.showToast({
|
|
231
|
+
body: {
|
|
232
|
+
title: `${formatTokens(totalTokens)} tokens`,
|
|
233
|
+
message: `${formatCost(cost)} | Session: ${formatCost(stats.totalCost)}`,
|
|
234
|
+
variant: "info",
|
|
235
|
+
duration: config.toast.duration,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
187
240
|
}
|
|
188
|
-
catch { }
|
|
189
241
|
}
|
|
190
242
|
// Handle session idle (show summary)
|
|
191
243
|
if (event.type === "session.idle") {
|
|
244
|
+
if (!config.toast.enabled || !config.toast.showOnIdle)
|
|
245
|
+
return;
|
|
192
246
|
const props = event.properties;
|
|
193
247
|
const sessionId = props?.sessionID;
|
|
194
248
|
if (!sessionId)
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-token-tracker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Real-time token usage and cost tracking plugin for OpenCode with Toast notifications and CLI stats",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"opencode-tokens": "
|
|
9
|
+
"opencode-tokens": "dist/bin/opencode-tokens.js"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|