opencode-token-tracker 1.0.0 → 1.2.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 +112 -5
- package/dist/bin/opencode-tokens.d.ts +2 -0
- package/dist/bin/opencode-tokens.js +289 -0
- package/dist/index.js +80 -26
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Real-time token usage and cost tracking plugin for [OpenCode](https://opencode.a
|
|
|
8
8
|
|
|
9
9
|
- **Real-time Toast notifications** - See token usage and cost after each AI response
|
|
10
10
|
- **Session statistics** - Track cumulative usage across your entire session
|
|
11
|
+
- **CLI statistics tool** - Query usage by day/week/month with breakdowns by model/agent
|
|
11
12
|
- **Cost calculation** - Automatic cost estimation based on model pricing
|
|
12
13
|
- **JSONL logging** - All usage data saved locally for analysis
|
|
13
14
|
- **Multi-model support** - Claude, GPT, DeepSeek, Gemini, and more
|
|
@@ -27,6 +28,8 @@ Restart OpenCode and the plugin will be automatically installed.
|
|
|
27
28
|
|
|
28
29
|
## Usage
|
|
29
30
|
|
|
31
|
+
### Toast Notifications
|
|
32
|
+
|
|
30
33
|
Once installed, you'll see Toast notifications after each AI response:
|
|
31
34
|
|
|
32
35
|
```
|
|
@@ -41,6 +44,56 @@ Session: 45.2K tokens
|
|
|
41
44
|
$0.156 | 8 msgs | 5min
|
|
42
45
|
```
|
|
43
46
|
|
|
47
|
+
### CLI Statistics
|
|
48
|
+
|
|
49
|
+
Query your token usage from the command line:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# All-time summary
|
|
53
|
+
opencode-tokens
|
|
54
|
+
|
|
55
|
+
# Today's usage
|
|
56
|
+
opencode-tokens today
|
|
57
|
+
|
|
58
|
+
# This week's usage with model breakdown
|
|
59
|
+
opencode-tokens week --by model
|
|
60
|
+
|
|
61
|
+
# This month with all breakdowns
|
|
62
|
+
opencode-tokens month --by all
|
|
63
|
+
|
|
64
|
+
# Day-by-day breakdown
|
|
65
|
+
opencode-tokens --by daily
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Example output:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Today's Usage
|
|
72
|
+
──────────────────────────────────────────────────
|
|
73
|
+
Total Tokens: 2.81M
|
|
74
|
+
Input: 2.74M
|
|
75
|
+
Output: 72.9K
|
|
76
|
+
Reasoning: 7.1K
|
|
77
|
+
Cache Read: 12.62M
|
|
78
|
+
Total Cost: $32.93
|
|
79
|
+
Messages: 230
|
|
80
|
+
|
|
81
|
+
By Model
|
|
82
|
+
─────────────────────────────────────────────────────
|
|
83
|
+
Model Tokens Cost Msgs
|
|
84
|
+
--------------- ---------- ---------- ------
|
|
85
|
+
claude-opus-4.5 2.70M $32.93 206
|
|
86
|
+
deepseek-chat 23.4K $0.0025 6
|
|
87
|
+
gpt-5.2 86.9K $0.0000 18
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Breakdown options (`--by`):
|
|
91
|
+
- `model` - Group by model (e.g., claude-opus-4.5)
|
|
92
|
+
- `agent` - Group by agent (e.g., sisyphus, coder)
|
|
93
|
+
- `provider` - Group by provider (e.g., anthropic, openai)
|
|
94
|
+
- `daily` - Show day-by-day breakdown
|
|
95
|
+
- `all` - Show all breakdowns
|
|
96
|
+
|
|
44
97
|
## Log Files
|
|
45
98
|
|
|
46
99
|
Token usage is logged to:
|
|
@@ -82,12 +135,66 @@ Unknown models use a default pricing estimate.
|
|
|
82
135
|
|
|
83
136
|
## Configuration
|
|
84
137
|
|
|
85
|
-
|
|
138
|
+
Create a config file at `~/.config/opencode/token-tracker.json`:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"providers": {
|
|
143
|
+
"github-copilot": { "input": 0, "output": 0 }
|
|
144
|
+
},
|
|
145
|
+
"models": {
|
|
146
|
+
"my-custom-model": { "input": 1, "output": 2 }
|
|
147
|
+
},
|
|
148
|
+
"toast": {
|
|
149
|
+
"enabled": true,
|
|
150
|
+
"duration": 3000,
|
|
151
|
+
"showOnIdle": true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Pricing Override
|
|
157
|
+
|
|
158
|
+
Pricing is resolved in this order (first match wins):
|
|
159
|
+
|
|
160
|
+
1. **Provider-level** - Override all models for a provider
|
|
161
|
+
2. **User model config** - Custom model pricing in config file
|
|
162
|
+
3. **Built-in pricing** - Default pricing table
|
|
163
|
+
4. **Fallback** - $1/M input, $4/M output
|
|
164
|
+
|
|
165
|
+
#### Example: Free providers
|
|
166
|
+
|
|
167
|
+
If you're using GitHub Copilot or other subscription-based services, set their cost to $0:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"providers": {
|
|
172
|
+
"github-copilot": { "input": 0, "output": 0 },
|
|
173
|
+
"cursor": { "input": 0, "output": 0 }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### Example: Custom model pricing
|
|
179
|
+
|
|
180
|
+
Override or add pricing for specific models:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"models": {
|
|
185
|
+
"claude-opus-4.5": { "input": 12, "output": 60, "cacheRead": 1.2 },
|
|
186
|
+
"my-local-model": { "input": 0, "output": 0 }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Toast Settings
|
|
86
192
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
| Option | Type | Default | Description |
|
|
194
|
+
|--------|------|---------|-------------|
|
|
195
|
+
| `enabled` | boolean | `true` | Show toast notifications |
|
|
196
|
+
| `duration` | number | `3000` | Toast display duration (ms) |
|
|
197
|
+
| `showOnIdle` | boolean | `true` | Show session summary on idle |
|
|
91
198
|
|
|
92
199
|
## Development
|
|
93
200
|
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
const LOG_FILE = join(homedir(), ".config", "opencode", "logs", "token-tracker", "tokens.jsonl");
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Helpers
|
|
8
|
+
// ============================================================================
|
|
9
|
+
function formatTokens(tokens) {
|
|
10
|
+
if (tokens >= 1_000_000)
|
|
11
|
+
return `${(tokens / 1_000_000).toFixed(2)}M`;
|
|
12
|
+
if (tokens >= 1_000)
|
|
13
|
+
return `${(tokens / 1_000).toFixed(1)}K`;
|
|
14
|
+
return tokens.toString();
|
|
15
|
+
}
|
|
16
|
+
function formatCost(cost) {
|
|
17
|
+
if (cost < 0.01)
|
|
18
|
+
return `$${cost.toFixed(4)}`;
|
|
19
|
+
if (cost < 1)
|
|
20
|
+
return `$${cost.toFixed(3)}`;
|
|
21
|
+
return `$${cost.toFixed(2)}`;
|
|
22
|
+
}
|
|
23
|
+
function padRight(str, len) {
|
|
24
|
+
return str.length >= len ? str : str + " ".repeat(len - str.length);
|
|
25
|
+
}
|
|
26
|
+
function padLeft(str, len) {
|
|
27
|
+
return str.length >= len ? str : " ".repeat(len - str.length) + str;
|
|
28
|
+
}
|
|
29
|
+
function getStartOfDay(date) {
|
|
30
|
+
const d = new Date(date);
|
|
31
|
+
d.setHours(0, 0, 0, 0);
|
|
32
|
+
return d.getTime();
|
|
33
|
+
}
|
|
34
|
+
function getStartOfWeek(date) {
|
|
35
|
+
const d = new Date(date);
|
|
36
|
+
const day = d.getDay();
|
|
37
|
+
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday as start
|
|
38
|
+
d.setDate(diff);
|
|
39
|
+
d.setHours(0, 0, 0, 0);
|
|
40
|
+
return d.getTime();
|
|
41
|
+
}
|
|
42
|
+
function getStartOfMonth(date) {
|
|
43
|
+
const d = new Date(date);
|
|
44
|
+
d.setDate(1);
|
|
45
|
+
d.setHours(0, 0, 0, 0);
|
|
46
|
+
return d.getTime();
|
|
47
|
+
}
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Data Loading
|
|
50
|
+
// ============================================================================
|
|
51
|
+
function loadEntries(since) {
|
|
52
|
+
if (!existsSync(LOG_FILE)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const content = readFileSync(LOG_FILE, "utf-8");
|
|
56
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
57
|
+
const entries = [];
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
try {
|
|
60
|
+
const entry = JSON.parse(line);
|
|
61
|
+
if (entry.type !== "tokens")
|
|
62
|
+
continue;
|
|
63
|
+
if (since && entry._ts < since)
|
|
64
|
+
continue;
|
|
65
|
+
if (!entry.input && !entry.output)
|
|
66
|
+
continue; // Skip empty entries
|
|
67
|
+
entries.push(entry);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Skip malformed lines
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Stats Aggregation
|
|
77
|
+
// ============================================================================
|
|
78
|
+
function createEmptyStats() {
|
|
79
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0, count: 0 };
|
|
80
|
+
}
|
|
81
|
+
function aggregateStats(entries) {
|
|
82
|
+
const stats = createEmptyStats();
|
|
83
|
+
for (const e of entries) {
|
|
84
|
+
stats.input += e.input ?? 0;
|
|
85
|
+
stats.output += e.output ?? 0;
|
|
86
|
+
stats.reasoning += e.reasoning ?? 0;
|
|
87
|
+
stats.cacheRead += e.cacheRead ?? 0;
|
|
88
|
+
stats.cacheWrite += e.cacheWrite ?? 0;
|
|
89
|
+
stats.cost += e.cost ?? 0;
|
|
90
|
+
stats.count += 1;
|
|
91
|
+
}
|
|
92
|
+
return stats;
|
|
93
|
+
}
|
|
94
|
+
function groupBy(entries, keyFn) {
|
|
95
|
+
const groups = new Map();
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const key = keyFn(e);
|
|
98
|
+
if (!groups.has(key)) {
|
|
99
|
+
groups.set(key, createEmptyStats());
|
|
100
|
+
}
|
|
101
|
+
const stats = groups.get(key);
|
|
102
|
+
stats.input += e.input ?? 0;
|
|
103
|
+
stats.output += e.output ?? 0;
|
|
104
|
+
stats.reasoning += e.reasoning ?? 0;
|
|
105
|
+
stats.cacheRead += e.cacheRead ?? 0;
|
|
106
|
+
stats.cacheWrite += e.cacheWrite ?? 0;
|
|
107
|
+
stats.cost += e.cost ?? 0;
|
|
108
|
+
stats.count += 1;
|
|
109
|
+
}
|
|
110
|
+
return groups;
|
|
111
|
+
}
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Display
|
|
114
|
+
// ============================================================================
|
|
115
|
+
function printSummary(title, stats) {
|
|
116
|
+
const totalTokens = stats.input + stats.output;
|
|
117
|
+
console.log();
|
|
118
|
+
console.log(` ${title}`);
|
|
119
|
+
console.log(` ${"─".repeat(50)}`);
|
|
120
|
+
console.log(` Total Tokens: ${padLeft(formatTokens(totalTokens), 12)}`);
|
|
121
|
+
console.log(` Input: ${padLeft(formatTokens(stats.input), 12)}`);
|
|
122
|
+
console.log(` Output: ${padLeft(formatTokens(stats.output), 12)}`);
|
|
123
|
+
if (stats.reasoning > 0) {
|
|
124
|
+
console.log(` Reasoning: ${padLeft(formatTokens(stats.reasoning), 12)}`);
|
|
125
|
+
}
|
|
126
|
+
console.log(` Cache Read: ${padLeft(formatTokens(stats.cacheRead), 12)}`);
|
|
127
|
+
console.log(` Total Cost: ${padLeft(formatCost(stats.cost), 12)}`);
|
|
128
|
+
console.log(` Messages: ${padLeft(stats.count.toString(), 12)}`);
|
|
129
|
+
console.log();
|
|
130
|
+
}
|
|
131
|
+
function printTable(title, groups, labelHeader) {
|
|
132
|
+
// Sort by cost descending
|
|
133
|
+
const sorted = Array.from(groups.entries()).sort((a, b) => b[1].cost - a[1].cost);
|
|
134
|
+
if (sorted.length === 0) {
|
|
135
|
+
console.log(`\n No data for ${title}\n`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Calculate column widths
|
|
139
|
+
const labelWidth = Math.max(labelHeader.length, ...sorted.map(([k]) => k.length));
|
|
140
|
+
const tokensWidth = 10;
|
|
141
|
+
const costWidth = 10;
|
|
142
|
+
const countWidth = 6;
|
|
143
|
+
console.log();
|
|
144
|
+
console.log(` ${title}`);
|
|
145
|
+
console.log(` ${"─".repeat(labelWidth + tokensWidth + costWidth + countWidth + 12)}`);
|
|
146
|
+
// Header
|
|
147
|
+
console.log(` ${padRight(labelHeader, labelWidth)} ${padLeft("Tokens", tokensWidth)} ${padLeft("Cost", costWidth)} ${padLeft("Msgs", countWidth)}`);
|
|
148
|
+
console.log(` ${"-".repeat(labelWidth)} ${"-".repeat(tokensWidth)} ${"-".repeat(costWidth)} ${"-".repeat(countWidth)}`);
|
|
149
|
+
// Rows
|
|
150
|
+
for (const [label, stats] of sorted) {
|
|
151
|
+
const totalTokens = stats.input + stats.output;
|
|
152
|
+
console.log(` ${padRight(label, labelWidth)} ${padLeft(formatTokens(totalTokens), tokensWidth)} ${padLeft(formatCost(stats.cost), costWidth)} ${padLeft(stats.count.toString(), countWidth)}`);
|
|
153
|
+
}
|
|
154
|
+
console.log();
|
|
155
|
+
}
|
|
156
|
+
function printDailyBreakdown(entries) {
|
|
157
|
+
const byDay = groupBy(entries, (e) => {
|
|
158
|
+
const date = new Date(e._ts);
|
|
159
|
+
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
160
|
+
});
|
|
161
|
+
// Sort by date descending
|
|
162
|
+
const sorted = Array.from(byDay.entries()).sort((a, b) => b[0].localeCompare(a[0]));
|
|
163
|
+
if (sorted.length === 0) {
|
|
164
|
+
console.log("\n No data\n");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const dateWidth = 12;
|
|
168
|
+
const tokensWidth = 10;
|
|
169
|
+
const costWidth = 10;
|
|
170
|
+
const countWidth = 6;
|
|
171
|
+
console.log();
|
|
172
|
+
console.log(` Daily Breakdown`);
|
|
173
|
+
console.log(` ${"─".repeat(dateWidth + tokensWidth + costWidth + countWidth + 12)}`);
|
|
174
|
+
console.log(` ${padRight("Date", dateWidth)} ${padLeft("Tokens", tokensWidth)} ${padLeft("Cost", costWidth)} ${padLeft("Msgs", countWidth)}`);
|
|
175
|
+
console.log(` ${"-".repeat(dateWidth)} ${"-".repeat(tokensWidth)} ${"-".repeat(costWidth)} ${"-".repeat(countWidth)}`);
|
|
176
|
+
for (const [date, stats] of sorted) {
|
|
177
|
+
const totalTokens = stats.input + stats.output;
|
|
178
|
+
console.log(` ${padRight(date, dateWidth)} ${padLeft(formatTokens(totalTokens), tokensWidth)} ${padLeft(formatCost(stats.cost), costWidth)} ${padLeft(stats.count.toString(), countWidth)}`);
|
|
179
|
+
}
|
|
180
|
+
console.log();
|
|
181
|
+
}
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// Commands
|
|
184
|
+
// ============================================================================
|
|
185
|
+
function cmdStats(period, breakdown) {
|
|
186
|
+
const now = new Date();
|
|
187
|
+
let since;
|
|
188
|
+
let title;
|
|
189
|
+
switch (period) {
|
|
190
|
+
case "today":
|
|
191
|
+
since = getStartOfDay(now);
|
|
192
|
+
title = "Today's Usage";
|
|
193
|
+
break;
|
|
194
|
+
case "week":
|
|
195
|
+
since = getStartOfWeek(now);
|
|
196
|
+
title = "This Week's Usage";
|
|
197
|
+
break;
|
|
198
|
+
case "month":
|
|
199
|
+
since = getStartOfMonth(now);
|
|
200
|
+
title = "This Month's Usage";
|
|
201
|
+
break;
|
|
202
|
+
case "all":
|
|
203
|
+
default:
|
|
204
|
+
since = undefined;
|
|
205
|
+
title = "All-Time Usage";
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
const entries = loadEntries(since);
|
|
209
|
+
if (entries.length === 0) {
|
|
210
|
+
console.log(`\n No data for ${title.toLowerCase()}\n`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Overall summary
|
|
214
|
+
const total = aggregateStats(entries);
|
|
215
|
+
printSummary(title, total);
|
|
216
|
+
// Breakdown
|
|
217
|
+
switch (breakdown) {
|
|
218
|
+
case "model":
|
|
219
|
+
printTable("By Model", groupBy(entries, (e) => e.model ?? "unknown"), "Model");
|
|
220
|
+
break;
|
|
221
|
+
case "agent":
|
|
222
|
+
printTable("By Agent", groupBy(entries, (e) => e.agent ?? "unknown"), "Agent");
|
|
223
|
+
break;
|
|
224
|
+
case "provider":
|
|
225
|
+
printTable("By Provider", groupBy(entries, (e) => e.provider ?? "unknown"), "Provider");
|
|
226
|
+
break;
|
|
227
|
+
case "day":
|
|
228
|
+
case "daily":
|
|
229
|
+
printDailyBreakdown(entries);
|
|
230
|
+
break;
|
|
231
|
+
case "all":
|
|
232
|
+
printTable("By Model", groupBy(entries, (e) => e.model ?? "unknown"), "Model");
|
|
233
|
+
printTable("By Agent", groupBy(entries, (e) => e.agent ?? "unknown"), "Agent");
|
|
234
|
+
printTable("By Provider", groupBy(entries, (e) => e.provider ?? "unknown"), "Provider");
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function cmdHelp() {
|
|
239
|
+
console.log(`
|
|
240
|
+
opencode-tokens - Token usage statistics CLI
|
|
241
|
+
|
|
242
|
+
Usage:
|
|
243
|
+
opencode-tokens [period] [--by <breakdown>]
|
|
244
|
+
|
|
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)
|
|
250
|
+
|
|
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
|
|
257
|
+
|
|
258
|
+
Examples:
|
|
259
|
+
opencode-tokens # All-time summary
|
|
260
|
+
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
|
|
264
|
+
`);
|
|
265
|
+
}
|
|
266
|
+
// ============================================================================
|
|
267
|
+
// Main
|
|
268
|
+
// ============================================================================
|
|
269
|
+
function main() {
|
|
270
|
+
const args = process.argv.slice(2);
|
|
271
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
272
|
+
cmdHelp();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Parse arguments
|
|
276
|
+
let period = "all";
|
|
277
|
+
let breakdown;
|
|
278
|
+
for (let i = 0; i < args.length; i++) {
|
|
279
|
+
const arg = args[i];
|
|
280
|
+
if (arg === "--by" || arg === "-b") {
|
|
281
|
+
breakdown = args[++i];
|
|
282
|
+
}
|
|
283
|
+
else if (["today", "week", "month", "all"].includes(arg)) {
|
|
284
|
+
period = arg;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
cmdStats(period, breakdown);
|
|
288
|
+
}
|
|
289
|
+
main();
|
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,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-token-tracker",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Real-time token usage and cost tracking plugin for OpenCode with Toast notifications",
|
|
3
|
+
"version": "1.2.0",
|
|
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
|
+
"bin": {
|
|
9
|
+
"opencode-tokens": "dist/bin/opencode-tokens.js"
|
|
10
|
+
},
|
|
8
11
|
"scripts": {
|
|
9
12
|
"build": "tsc",
|
|
10
13
|
"prepublishOnly": "npm run build"
|