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 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
- Currently the plugin works out of the box with no configuration needed.
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
- Future versions may support:
141
- - Custom pricing overrides
142
- - Toast display options
143
- - Export formats
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 LOG_FILE = join(homedir(), ".config", "opencode", "logs", "token-tracker", "tokens.jsonl");
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); // Monday as start
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; // Skip empty entries
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); // YYYY-MM-DD
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 [period] [--by <breakdown>]
456
+ opencode-tokens [command] [options]
244
457
 
245
- Period:
246
- today Show today's usage
247
- week Show this week's usage
248
- month Show this month's usage
249
- all Show all-time usage (default)
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
- Breakdown (--by):
252
- model Group by model (e.g., claude-opus-4.5)
253
- agent Group by agent (e.g., sisyphus, coder)
254
- provider Group by provider (e.g., anthropic, openai)
255
- daily Show day-by-day breakdown
256
- all Show all breakdowns
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, grouped by model
262
- opencode-tokens month --by all # This month, all breakdowns
263
- opencode-tokens --by daily # All-time, day by day
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
- // Parse arguments
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 LOG_DIR = join(homedir(), ".config", "opencode", "logs", "token-tracker");
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
- // Prices as of 2026-02 (update as needed)
7
- const PRICING = {
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
- function getModelPricing(model) {
42
- // Try exact match first
43
- if (PRICING[model])
44
- return PRICING[model];
45
- // Try partial match (e.g., "claude-opus-4.5" matches "claude-opus-4.5-xxx")
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(PRICING)) {
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
- return PRICING["_default"];
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
- logJson({ type: "init", directory });
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
- const totalTokens = input + output;
178
- try {
179
- await client.tui.showToast({
180
- body: {
181
- title: `${formatTokens(totalTokens)} tokens`,
182
- message: `${formatCost(cost)} | Session: ${formatCost(stats.totalCost)}`,
183
- variant: "info",
184
- duration: 3000,
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.1.0",
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": "./dist/bin/opencode-tokens.js"
9
+ "opencode-tokens": "dist/bin/opencode-tokens.js"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",