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