opencode-token-tracker 1.4.0 → 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/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,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
  }
@@ -157,20 +85,6 @@ function getOrCreateSessionStats(sessionId) {
157
85
  }
158
86
  return sessionStats.get(sessionId);
159
87
  }
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
88
  // ============================================================================
175
89
  // Deduplication
176
90
  // ============================================================================
@@ -199,65 +113,129 @@ function logJson(data) {
199
113
  const entry = JSON.stringify({ ...data, _ts: Date.now() }) + "\n";
200
114
  appendFileSync(LOG_FILE, entry);
201
115
  }
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) {
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) {
222
130
  if (!existsSync(LOG_FILE))
223
- return [];
131
+ return 0;
224
132
  try {
225
133
  const content = readFileSync(LOG_FILE, "utf-8");
226
134
  const lines = content.trim().split("\n").filter(Boolean);
227
- const entries = [];
135
+ let total = 0;
228
136
  for (const line of lines) {
229
137
  try {
230
138
  const entry = JSON.parse(line);
231
139
  if (entry.type === "tokens" && entry._ts >= since && entry.cost) {
232
- entries.push(entry);
140
+ total += entry.cost;
233
141
  }
234
142
  }
235
143
  catch { }
236
144
  }
237
- return entries;
145
+ return total;
238
146
  }
239
147
  catch {
240
- return [];
148
+ return 0;
241
149
  }
242
150
  }
243
- function calculateSpentSince(since) {
244
- const entries = loadEntriesSince(since);
245
- return entries.reduce((sum, e) => sum + (e.cost ?? 0), 0);
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;
246
224
  }
247
225
  function checkBudgetStatus() {
248
226
  const budget = config.budget;
249
227
  if (!budget.daily && !budget.weekly && !budget.monthly) {
250
228
  return null;
251
229
  }
230
+ if (!budgetTracker.initialized)
231
+ return null;
252
232
  const warnAt = budget.warnAt ?? 0.8;
253
- const now = new Date();
254
233
  // Check in order: daily -> weekly -> monthly (most restrictive first)
255
234
  if (budget.daily) {
256
- const spent = calculateSpentSince(getStartOfDay(now));
257
- const percentage = spent / budget.daily;
235
+ const percentage = budgetTracker.dailySpent / budget.daily;
258
236
  return {
259
237
  period: "daily",
260
- spent,
238
+ spent: budgetTracker.dailySpent,
261
239
  limit: budget.daily,
262
240
  percentage,
263
241
  exceeded: percentage >= 1,
@@ -265,11 +243,10 @@ function checkBudgetStatus() {
265
243
  };
266
244
  }
267
245
  if (budget.weekly) {
268
- const spent = calculateSpentSince(getStartOfWeek(now));
269
- const percentage = spent / budget.weekly;
246
+ const percentage = budgetTracker.weeklySpent / budget.weekly;
270
247
  return {
271
248
  period: "weekly",
272
- spent,
249
+ spent: budgetTracker.weeklySpent,
273
250
  limit: budget.weekly,
274
251
  percentage,
275
252
  exceeded: percentage >= 1,
@@ -277,11 +254,10 @@ function checkBudgetStatus() {
277
254
  };
278
255
  }
279
256
  if (budget.monthly) {
280
- const spent = calculateSpentSince(getStartOfMonth(now));
281
- const percentage = spent / budget.monthly;
257
+ const percentage = budgetTracker.monthlySpent / budget.monthly;
282
258
  return {
283
259
  period: "monthly",
284
- spent,
260
+ spent: budgetTracker.monthlySpent,
285
261
  limit: budget.monthly,
286
262
  percentage,
287
263
  exceeded: percentage >= 1,
@@ -296,9 +272,25 @@ function formatBudgetMessage(status) {
296
272
  return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
297
273
  }
298
274
  export const TokenTrackerPlugin = async ({ directory, client }) => {
299
- // Load config on plugin init
275
+ // Load config on plugin init (with validation)
300
276
  config = loadConfig();
277
+ // Initialize in-memory budget tracker (reads JSONL once)
278
+ initBudgetTracker();
301
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
+ }
302
294
  return {
303
295
  event: async ({ event }) => {
304
296
  try {
@@ -351,6 +343,8 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
351
343
  cacheWrite,
352
344
  cost,
353
345
  });
346
+ // Accumulate cost into in-memory budget tracker
347
+ accumulateBudget(cost);
354
348
  // Show toast for this message
355
349
  if (config.toast.enabled) {
356
350
  const totalTokens = input + output;
@@ -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 {};