opencode-token-tracker 1.4.0 → 1.5.1

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/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
1
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
2
3
  import { join } from "path";
3
4
  import { homedir } from "os";
@@ -5,97 +6,24 @@ const CONFIG_DIR = join(homedir(), ".config", "opencode");
5
6
  const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
6
7
  const LOG_DIR = join(CONFIG_DIR, "logs", "token-tracker");
7
8
  const LOG_FILE = join(LOG_DIR, "tokens.jsonl");
8
- const DEFAULT_CONFIG = {
9
- providers: {},
10
- models: {},
11
- toast: {
12
- enabled: true,
13
- duration: 3000,
14
- showOnIdle: true,
15
- },
16
- budget: {
17
- warnAt: 0.8,
18
- },
19
- };
20
- // Built-in pricing table (USD per 1M tokens) - Updated 2026-02-05
21
- // Sources:
22
- // - Anthropic: https://www.anthropic.com/pricing#api
23
- // - OpenAI: https://openai.com/api/pricing/
24
- // - DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
25
- // - Google: https://cloud.google.com/vertex-ai/generative-ai/pricing
26
- const BUILTIN_PRICING = {
27
- // Anthropic Claude (https://www.anthropic.com/pricing#api)
28
- // Opus 4.5: $5 input, $25 output, cache write $6.25, cache read $0.50
29
- "claude-opus-4.5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
30
- // Sonnet 4.5: $3 input, $15 output (≤200K), cache write $3.75, cache read $0.30
31
- "claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
32
- // Sonnet 4: $3 input, $15 output
33
- "claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
34
- // Haiku 4.5: $1 input, $5 output, cache write $1.25, cache read $0.10
35
- "claude-haiku-4.5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
36
- "claude-haiku-4": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
37
- // Legacy models
38
- "claude-opus-4.1": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
39
- "claude-opus-4": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
40
- "claude-haiku-3": { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
41
- // OpenAI GPT (https://openai.com/api/pricing/)
42
- // GPT-5.2: $1.75 input, $14 output (flagship)
43
- "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175 },
44
- "gpt-5.2-pro": { input: 21, output: 168 },
45
- "gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025 },
46
- "gpt-5.1": { input: 2, output: 8 },
47
- "gpt-5": { input: 5, output: 15 },
48
- // GPT-4.1 series (fine-tuning prices, base may differ)
49
- "gpt-4.1": { input: 3, output: 12, cacheRead: 0.75 },
50
- "gpt-4.1-mini": { input: 0.8, output: 3.2, cacheRead: 0.2 },
51
- "gpt-4.1-nano": { input: 0.2, output: 0.8, cacheRead: 0.05 },
52
- // GPT-4o series (may be deprecated)
53
- "gpt-4o": { input: 2.5, output: 10 },
54
- "gpt-4o-mini": { input: 0.15, output: 0.6 },
55
- // Reasoning models
56
- "o3": { input: 10, output: 40 },
57
- "o3-mini": { input: 1.1, output: 4.4 },
58
- "o4-mini": { input: 4, output: 16, cacheRead: 1 },
59
- "o1": { input: 15, output: 60 },
60
- "o1-mini": { input: 1.1, output: 4.4 },
61
- // DeepSeek (https://api-docs.deepseek.com/quick_start/pricing)
62
- // DeepSeek-V3.2: unified pricing for both chat and reasoner
63
- // $0.28 input (cache miss), $0.028 input (cache hit), $0.42 output
64
- "deepseek-chat": { input: 0.28, output: 0.42, cacheRead: 0.028 },
65
- "deepseek-reasoner": { input: 0.28, output: 0.42, cacheRead: 0.028 },
66
- // Google Gemini (https://cloud.google.com/vertex-ai/generative-ai/pricing)
67
- // Gemini 3 Pro Preview: $2 input, $12 output (≤200K)
68
- "gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2 },
69
- "gemini-3-pro-preview": { input: 2, output: 12, cacheRead: 0.2 },
70
- // Gemini 3 Flash Preview: $0.5 input
71
- "gemini-3-flash": { input: 0.5, output: 2, cacheRead: 0.05 },
72
- "gemini-3-flash-preview": { input: 0.5, output: 2, cacheRead: 0.05 },
73
- // Gemini 2.5 Pro: $1.25 input, $10 output (≤200K)
74
- "gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125 },
75
- // Gemini 2.5 Flash Lite: $0.1 input
76
- "gemini-2.5-flash": { input: 0.1, output: 0.4, cacheRead: 0.01 },
77
- "gemini-2.5-flash-lite": { input: 0.1, output: 0.4, cacheRead: 0.01 },
78
- // Gemini 2.0 Flash: $0.15 input
79
- "gemini-2.0-flash": { input: 0.15, output: 0.6, cacheRead: 0.015 },
80
- // Fallback for unknown models
81
- "_default": { input: 1, output: 4 },
82
- };
9
+ // ============================================================================
10
+ // Configuration
11
+ // ============================================================================
83
12
  let config = DEFAULT_CONFIG;
13
+ let configWarnings = [];
84
14
  function loadConfig() {
85
15
  try {
86
16
  if (existsSync(CONFIG_FILE)) {
87
17
  const content = readFileSync(CONFIG_FILE, "utf-8");
88
- const userConfig = JSON.parse(content);
89
- return {
90
- providers: { ...DEFAULT_CONFIG.providers, ...userConfig.providers },
91
- models: { ...DEFAULT_CONFIG.models, ...userConfig.models },
92
- toast: { ...DEFAULT_CONFIG.toast, ...userConfig.toast },
93
- budget: { ...DEFAULT_CONFIG.budget, ...userConfig.budget },
94
- };
18
+ const raw = JSON.parse(content);
19
+ const result = validateConfig(raw);
20
+ configWarnings = result.warnings;
21
+ return result.config;
95
22
  }
96
23
  }
97
- catch (e) {
98
- // Config parse error - use defaults
24
+ catch {
25
+ // JSON parse error - use defaults
26
+ configWarnings = ["Config file is not valid JSON, using defaults"];
99
27
  }
100
28
  return DEFAULT_CONFIG;
101
29
  }
@@ -108,27 +36,22 @@ function getModelPricing(model, provider) {
108
36
  return config.providers[provider];
109
37
  }
110
38
  // 2. Check user-defined model pricing
111
- if (config.models[model]) {
112
- return config.models[model];
39
+ const configuredPricing = findModelConfigPricing(config.models, model, provider);
40
+ if (configuredPricing) {
41
+ return configuredPricing;
113
42
  }
114
43
  // 3. Check built-in exact match
115
44
  if (BUILTIN_PRICING[model]) {
116
45
  return BUILTIN_PRICING[model];
117
46
  }
118
- // 4. Try partial match in user config
47
+ // 4. Try partial match in built-in pricing
119
48
  const modelLower = model.toLowerCase();
120
- for (const [key, pricing] of Object.entries(config.models)) {
121
- if (modelLower.includes(key.toLowerCase())) {
122
- return pricing;
123
- }
124
- }
125
- // 5. Try partial match in built-in pricing
126
49
  for (const [key, pricing] of Object.entries(BUILTIN_PRICING)) {
127
50
  if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
128
51
  return pricing;
129
52
  }
130
53
  }
131
- // 6. Fallback to default
54
+ // 5. Fallback to default
132
55
  return BUILTIN_PRICING["_default"];
133
56
  }
134
57
  function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
@@ -157,20 +80,6 @@ function getOrCreateSessionStats(sessionId) {
157
80
  }
158
81
  return sessionStats.get(sessionId);
159
82
  }
160
- function formatCost(cost) {
161
- if (cost < 0.01)
162
- return `$${cost.toFixed(4)}`;
163
- if (cost < 1)
164
- return `$${cost.toFixed(3)}`;
165
- return `$${cost.toFixed(2)}`;
166
- }
167
- function formatTokens(tokens) {
168
- if (tokens >= 1_000_000)
169
- return `${(tokens / 1_000_000).toFixed(1)}M`;
170
- if (tokens >= 1_000)
171
- return `${(tokens / 1_000).toFixed(1)}K`;
172
- return tokens.toString();
173
- }
174
83
  // ============================================================================
175
84
  // Deduplication
176
85
  // ============================================================================
@@ -199,65 +108,129 @@ function logJson(data) {
199
108
  const entry = JSON.stringify({ ...data, _ts: Date.now() }) + "\n";
200
109
  appendFileSync(LOG_FILE, entry);
201
110
  }
202
- function getStartOfDay(date = new Date()) {
203
- const d = new Date(date);
204
- d.setHours(0, 0, 0, 0);
205
- return d.getTime();
206
- }
207
- function getStartOfWeek(date = new Date()) {
208
- const d = new Date(date);
209
- const day = d.getDay();
210
- const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday as first day
211
- d.setDate(diff);
212
- d.setHours(0, 0, 0, 0);
213
- return d.getTime();
214
- }
215
- function getStartOfMonth(date = new Date()) {
216
- const d = new Date(date);
217
- d.setDate(1);
218
- d.setHours(0, 0, 0, 0);
219
- return d.getTime();
220
- }
221
- function loadEntriesSince(since) {
111
+ const budgetTracker = {
112
+ dailySpent: 0,
113
+ weeklySpent: 0,
114
+ monthlySpent: 0,
115
+ dayStart: 0,
116
+ weekStart: 0,
117
+ monthStart: 0,
118
+ initialized: false,
119
+ };
120
+ /**
121
+ * Load cost entries from JSONL since a given timestamp.
122
+ * Used only during initialization and period rollovers.
123
+ */
124
+ function loadCostsSince(since) {
222
125
  if (!existsSync(LOG_FILE))
223
- return [];
126
+ return 0;
224
127
  try {
225
128
  const content = readFileSync(LOG_FILE, "utf-8");
226
129
  const lines = content.trim().split("\n").filter(Boolean);
227
- const entries = [];
130
+ let total = 0;
228
131
  for (const line of lines) {
229
132
  try {
230
133
  const entry = JSON.parse(line);
231
134
  if (entry.type === "tokens" && entry._ts >= since && entry.cost) {
232
- entries.push(entry);
135
+ total += entry.cost;
233
136
  }
234
137
  }
235
138
  catch { }
236
139
  }
237
- return entries;
140
+ return total;
238
141
  }
239
142
  catch {
240
- return [];
143
+ return 0;
144
+ }
145
+ }
146
+ /**
147
+ * Initialize budgetTracker from JSONL file (called once at plugin init).
148
+ */
149
+ function initBudgetTracker() {
150
+ const now = new Date();
151
+ budgetTracker.dayStart = getStartOfDay(now);
152
+ budgetTracker.weekStart = getStartOfWeek(now);
153
+ budgetTracker.monthStart = getStartOfMonth(now);
154
+ // Only load from file if budget is configured
155
+ const budget = config.budget;
156
+ if (!budget.daily && !budget.weekly && !budget.monthly) {
157
+ budgetTracker.initialized = true;
158
+ return;
241
159
  }
160
+ // Load once using the earliest period boundary
161
+ const earliest = Math.min(budgetTracker.dayStart, budgetTracker.weekStart, budgetTracker.monthStart);
162
+ if (!existsSync(LOG_FILE)) {
163
+ budgetTracker.initialized = true;
164
+ return;
165
+ }
166
+ try {
167
+ const content = readFileSync(LOG_FILE, "utf-8");
168
+ const lines = content.trim().split("\n").filter(Boolean);
169
+ let daily = 0;
170
+ let weekly = 0;
171
+ let monthly = 0;
172
+ for (const line of lines) {
173
+ try {
174
+ const entry = JSON.parse(line);
175
+ if (entry.type !== "tokens" || !entry.cost || entry._ts < earliest)
176
+ continue;
177
+ if (entry._ts >= budgetTracker.dayStart)
178
+ daily += entry.cost;
179
+ if (entry._ts >= budgetTracker.weekStart)
180
+ weekly += entry.cost;
181
+ if (entry._ts >= budgetTracker.monthStart)
182
+ monthly += entry.cost;
183
+ }
184
+ catch { }
185
+ }
186
+ budgetTracker.dailySpent = daily;
187
+ budgetTracker.weeklySpent = weekly;
188
+ budgetTracker.monthlySpent = monthly;
189
+ }
190
+ catch { }
191
+ budgetTracker.initialized = true;
242
192
  }
243
- function calculateSpentSince(since) {
244
- const entries = loadEntriesSince(since);
245
- return entries.reduce((sum, e) => sum + (e.cost ?? 0), 0);
193
+ /**
194
+ * Accumulate cost into budgetTracker after a new token entry is logged.
195
+ */
196
+ function accumulateBudget(cost) {
197
+ if (!budgetTracker.initialized)
198
+ return;
199
+ const now = new Date();
200
+ const currentDayStart = getStartOfDay(now);
201
+ const currentWeekStart = getStartOfWeek(now);
202
+ const currentMonthStart = getStartOfMonth(now);
203
+ // Period rollover detection — reset and reload from file for accuracy
204
+ if (currentDayStart !== budgetTracker.dayStart) {
205
+ budgetTracker.dayStart = currentDayStart;
206
+ budgetTracker.dailySpent = loadCostsSince(currentDayStart);
207
+ }
208
+ if (currentWeekStart !== budgetTracker.weekStart) {
209
+ budgetTracker.weekStart = currentWeekStart;
210
+ budgetTracker.weeklySpent = loadCostsSince(currentWeekStart);
211
+ }
212
+ if (currentMonthStart !== budgetTracker.monthStart) {
213
+ budgetTracker.monthStart = currentMonthStart;
214
+ budgetTracker.monthlySpent = loadCostsSince(currentMonthStart);
215
+ }
216
+ budgetTracker.dailySpent += cost;
217
+ budgetTracker.weeklySpent += cost;
218
+ budgetTracker.monthlySpent += cost;
246
219
  }
247
220
  function checkBudgetStatus() {
248
221
  const budget = config.budget;
249
222
  if (!budget.daily && !budget.weekly && !budget.monthly) {
250
223
  return null;
251
224
  }
225
+ if (!budgetTracker.initialized)
226
+ return null;
252
227
  const warnAt = budget.warnAt ?? 0.8;
253
- const now = new Date();
254
228
  // Check in order: daily -> weekly -> monthly (most restrictive first)
255
229
  if (budget.daily) {
256
- const spent = calculateSpentSince(getStartOfDay(now));
257
- const percentage = spent / budget.daily;
230
+ const percentage = budgetTracker.dailySpent / budget.daily;
258
231
  return {
259
232
  period: "daily",
260
- spent,
233
+ spent: budgetTracker.dailySpent,
261
234
  limit: budget.daily,
262
235
  percentage,
263
236
  exceeded: percentage >= 1,
@@ -265,11 +238,10 @@ function checkBudgetStatus() {
265
238
  };
266
239
  }
267
240
  if (budget.weekly) {
268
- const spent = calculateSpentSince(getStartOfWeek(now));
269
- const percentage = spent / budget.weekly;
241
+ const percentage = budgetTracker.weeklySpent / budget.weekly;
270
242
  return {
271
243
  period: "weekly",
272
- spent,
244
+ spent: budgetTracker.weeklySpent,
273
245
  limit: budget.weekly,
274
246
  percentage,
275
247
  exceeded: percentage >= 1,
@@ -277,11 +249,10 @@ function checkBudgetStatus() {
277
249
  };
278
250
  }
279
251
  if (budget.monthly) {
280
- const spent = calculateSpentSince(getStartOfMonth(now));
281
- const percentage = spent / budget.monthly;
252
+ const percentage = budgetTracker.monthlySpent / budget.monthly;
282
253
  return {
283
254
  period: "monthly",
284
- spent,
255
+ spent: budgetTracker.monthlySpent,
285
256
  limit: budget.monthly,
286
257
  percentage,
287
258
  exceeded: percentage >= 1,
@@ -296,9 +267,25 @@ function formatBudgetMessage(status) {
296
267
  return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
297
268
  }
298
269
  export const TokenTrackerPlugin = async ({ directory, client }) => {
299
- // Load config on plugin init
270
+ // Load config on plugin init (with validation)
300
271
  config = loadConfig();
272
+ // Initialize in-memory budget tracker (reads JSONL once)
273
+ initBudgetTracker();
301
274
  logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
275
+ // Show config validation warnings via Toast
276
+ if (configWarnings.length > 0) {
277
+ try {
278
+ await client.tui.showToast({
279
+ body: {
280
+ title: "Token Tracker: config warning",
281
+ message: configWarnings.join("; "),
282
+ variant: "warning",
283
+ duration: 5000,
284
+ },
285
+ });
286
+ }
287
+ catch { }
288
+ }
302
289
  return {
303
290
  event: async ({ event }) => {
304
291
  try {
@@ -351,6 +338,8 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
351
338
  cacheWrite,
352
339
  cost,
353
340
  });
341
+ // Accumulate cost into in-memory budget tracker
342
+ accumulateBudget(cost);
354
343
  // Show toast for this message
355
344
  if (config.toast.enabled) {
356
345
  const totalTokens = input + output;
@@ -0,0 +1,48 @@
1
+ export interface ModelPricing {
2
+ input: number;
3
+ output: number;
4
+ cacheRead?: number;
5
+ cacheWrite?: number;
6
+ }
7
+ export interface ProviderModelPricingMap {
8
+ [provider: string]: ModelPricing;
9
+ }
10
+ export declare const BUILTIN_PRICING: Record<string, ModelPricing>;
11
+ export declare function formatCost(cost: number): string;
12
+ /**
13
+ * Format token count for display.
14
+ * @param tokens - raw token count
15
+ * @param millionDecimals - decimal places for M-level values (default 1)
16
+ */
17
+ export declare function formatTokens(tokens: number, millionDecimals?: number): string;
18
+ export declare function getStartOfDay(date?: Date): number;
19
+ export declare function getStartOfWeek(date?: Date): number;
20
+ export declare function getStartOfMonth(date?: Date): number;
21
+ export interface ToastConfig {
22
+ enabled: boolean;
23
+ duration: number;
24
+ showOnIdle: boolean;
25
+ }
26
+ export interface BudgetConfig {
27
+ daily?: number;
28
+ weekly?: number;
29
+ monthly?: number;
30
+ warnAt: number;
31
+ }
32
+ export interface TrackerConfig {
33
+ providers: Record<string, ModelPricing>;
34
+ models: Record<string, ModelPricing | ProviderModelPricingMap>;
35
+ toast: ToastConfig;
36
+ budget: BudgetConfig;
37
+ }
38
+ export declare const DEFAULT_CONFIG: TrackerConfig;
39
+ export interface ConfigValidationResult {
40
+ config: TrackerConfig;
41
+ warnings: string[];
42
+ }
43
+ /**
44
+ * Validate and normalize raw config (from JSON.parse).
45
+ * Invalid fields are silently corrected to defaults with warnings.
46
+ */
47
+ export declare function validateConfig(raw: unknown): ConfigValidationResult;
48
+ export declare function findModelConfigPricing(models: TrackerConfig["models"], model: string, provider: string): ModelPricing | undefined;