@lemantorus/opencode-analytics 1.0.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.
@@ -0,0 +1,491 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const db = require('./db');
5
+
6
+ const PRICING_FILE = path.join(__dirname, 'pricing.json');
7
+ const USER_PRICES_FILE = path.join(__dirname, 'user-prices.json');
8
+ const MODELS_DEV_URL = 'https://models.dev/api.json';
9
+
10
+ let pricing = loadPricing();
11
+ let userPrices = loadUserPrices();
12
+ let modelsDevData = null;
13
+
14
+ function loadUserPrices() {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(USER_PRICES_FILE, 'utf8'));
17
+ } catch {
18
+ return { lastUpdated: null, prices: {} };
19
+ }
20
+ }
21
+
22
+ function saveUserPrices() {
23
+ fs.writeFileSync(USER_PRICES_FILE, JSON.stringify(userPrices, null, 2));
24
+ }
25
+
26
+ function loadPricing() {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(PRICING_FILE, 'utf8'));
29
+ } catch {
30
+ return { models: {}, overrides: {} };
31
+ }
32
+ }
33
+
34
+ function savePricing() {
35
+ fs.writeFileSync(PRICING_FILE, JSON.stringify(pricing, null, 2));
36
+ }
37
+
38
+ function parseModelId(modelId) {
39
+ if (!modelId) return null;
40
+
41
+ const isFree = modelId.includes('-free') ||
42
+ modelId === 'big-pickle' ||
43
+ modelId.includes('nemotron');
44
+
45
+ let baseName = modelId;
46
+
47
+ baseName = baseName
48
+ .replace(/^(zai-org\/|z-ai\/)/, '')
49
+ .replace(/^(minimax\/)/, 'minimax-')
50
+ .replace(/^(MiniMax-)/i, 'minimax-')
51
+ .replace(/^(x-ai\/)/, 'grok-')
52
+ .replace(/^(moonshotai\/)/, 'kimi-')
53
+ .replace(/^(nvidia\/)/, 'nemotron-')
54
+ .replace(/^thudm\//i, '')
55
+ .replace(/-maas$/, '')
56
+ .replace(/-flashx$/, '-flash')
57
+ .replace(/_free$/, '-free')
58
+ .toLowerCase();
59
+
60
+ if (isFree && !baseName.endsWith('-free')) {
61
+ baseName = baseName + '-free';
62
+ }
63
+
64
+ return { original: modelId, baseName, isFree };
65
+ }
66
+
67
+ function buildModelIndex(apiData) {
68
+ const index = {
69
+ byProvider: {},
70
+ byFamily: {},
71
+ all: []
72
+ };
73
+
74
+ for (const [provider, providerData] of Object.entries(apiData)) {
75
+ if (!providerData?.models) continue;
76
+
77
+ index.byProvider[provider] = {};
78
+
79
+ for (const [modelKey, model] of Object.entries(providerData.models)) {
80
+ if (!model) continue;
81
+
82
+ const entry = { provider, key: modelKey, model, isFree: false };
83
+ index.all.push(entry);
84
+ index.byProvider[provider][modelKey] = entry;
85
+
86
+ const family = model.family?.toLowerCase();
87
+ if (family) {
88
+ if (!index.byFamily[family]) index.byFamily[family] = [];
89
+ index.byFamily[family].push(entry);
90
+ }
91
+
92
+ const idLower = model.id?.toLowerCase();
93
+ if (idLower) {
94
+ if (!index.byFamily[idLower]) index.byFamily[idLower] = [];
95
+ index.byFamily[idLower].push(entry);
96
+ }
97
+ }
98
+ }
99
+
100
+ return index;
101
+ }
102
+
103
+ function findModelInApi(parsed, modelIndex) {
104
+ if (!parsed) return null;
105
+
106
+ const { original, baseName, isFree } = parsed;
107
+ const searchTerms = [
108
+ baseName,
109
+ baseName.replace(/-flash$/, ''),
110
+ baseName.replace(/-turbo$/, ''),
111
+ baseName.replace(/-air$/, ''),
112
+ baseName.split('-').slice(0, 2).join('-'),
113
+ baseName.split('-')[0],
114
+ original.replace(/^(zai-org\/|z-ai\/)/, '').replace(/-maas$/, '').replace(/-free$/, '').toLowerCase(),
115
+ original.toLowerCase(),
116
+ ].filter((v, i, a) => a.indexOf(v) === i);
117
+
118
+ const seen = new Set();
119
+
120
+ for (const term of searchTerms) {
121
+ if (!term || term.length < 2) continue;
122
+
123
+ if (modelIndex.byFamily[term]) {
124
+ for (const entry of modelIndex.byFamily[term]) {
125
+ const key = `${entry.provider}/${entry.key}`;
126
+ if (!seen.has(key)) {
127
+ seen.add(key);
128
+ return entry.model;
129
+ }
130
+ }
131
+ }
132
+
133
+ for (const entry of modelIndex.all) {
134
+ const fullKey = `${entry.provider}/${entry.key}`.toLowerCase();
135
+ if (fullKey.includes(term) || entry.key.toLowerCase().includes(term)) {
136
+ const key = `${entry.provider}/${entry.key}`;
137
+ if (!seen.has(key)) {
138
+ seen.add(key);
139
+ return entry.model;
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ return null;
146
+ }
147
+
148
+ async function fetchModelsDevPricing() {
149
+ try {
150
+ const res = await fetch(MODELS_DEV_URL);
151
+ const apiData = await res.json();
152
+ modelsDevData = apiData;
153
+
154
+ const modelIndex = buildModelIndex(apiData);
155
+ const usedModels = db.getModelsStats();
156
+ const newModels = {};
157
+
158
+ for (const model of usedModels) {
159
+ const parsed = parseModelId(model.modelId);
160
+ if (!parsed) continue;
161
+
162
+ const apiModel = findModelInApi(parsed, modelIndex);
163
+ if (!apiModel) continue;
164
+
165
+ const cost = apiModel.cost || {};
166
+
167
+ newModels[parsed.baseName] = {
168
+ input: (cost.input || 0) / 1000000,
169
+ output: (cost.output || 0) / 1000000,
170
+ cacheRead: (cost.cache_read || cost.input || 0) / 1000000,
171
+ cacheWrite: (cost.cache_write || 0) / 1000000,
172
+ isFree: parsed.isFree
173
+ };
174
+ }
175
+
176
+ pricing.models = { ...newModels, ...pricing.overrides };
177
+ pricing.lastUpdated = new Date().toISOString();
178
+ pricing.source = 'models.dev';
179
+ savePricing();
180
+
181
+ console.log('Pricing updated from models.dev:', pricing.lastUpdated, `(${Object.keys(newModels).length} models)`);
182
+ return true;
183
+ } catch (err) {
184
+ console.error('Failed to fetch models.dev pricing:', err.message);
185
+ return false;
186
+ }
187
+ }
188
+
189
+ function getPricingForModel(baseModel, checkAsFree = true) {
190
+ const isFreeModel = baseModel.endsWith('-free') || baseModel === 'big-pickle';
191
+ const paidName = isFreeModel ? baseModel.replace(/-free$/, '') : baseModel;
192
+
193
+ // 1. Check user prices first (highest priority)
194
+ if (userPrices.prices && userPrices.prices[baseModel]) {
195
+ return userPrices.prices[baseModel];
196
+ }
197
+
198
+ const shortName = db.getShortModelName(baseModel);
199
+ if (userPrices.prices && userPrices.prices[shortName]) {
200
+ return userPrices.prices[shortName];
201
+ }
202
+
203
+ // 2. Check server prices (pricing.json)
204
+ if (pricing.overrides && pricing.overrides[baseModel]) {
205
+ return pricing.overrides[baseModel];
206
+ }
207
+
208
+ if (!checkAsFree && isFreeModel && pricing.models && pricing.models[paidName]) {
209
+ return pricing.models[paidName];
210
+ }
211
+
212
+ if (pricing.models && pricing.models[baseModel]) {
213
+ const p = pricing.models[baseModel];
214
+ if (checkAsFree && p.isFree) {
215
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, isFree: true };
216
+ }
217
+ return p;
218
+ }
219
+
220
+ if (pricing.overrides && pricing.overrides[shortName]) {
221
+ return pricing.overrides[shortName];
222
+ }
223
+
224
+ if (!checkAsFree && isFreeModel && pricing.models && pricing.models[shortName] && !pricing.models[shortName].isFree) {
225
+ return pricing.models[shortName];
226
+ }
227
+
228
+ if (pricing.models && pricing.models[shortName]) {
229
+ const p = pricing.models[shortName];
230
+ if (checkAsFree && p.isFree) {
231
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, isFree: true };
232
+ }
233
+ return p;
234
+ }
235
+
236
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
237
+ }
238
+
239
+ function calculateCost(stats, checkAsFree = true) {
240
+ const baseModel = stats.baseModel || db.normalizeModelName(stats.modelId);
241
+ const modelPricing = getPricingForModel(baseModel, checkAsFree);
242
+
243
+ const inputCost = (stats.inputTokens || 0) * (modelPricing.input || 0);
244
+ const outputCost = (stats.outputTokens || 0) * (modelPricing.output || 0);
245
+ const cacheReadCost = (stats.cacheRead || 0) * (modelPricing.cacheRead || modelPricing.input || 0);
246
+ const cacheWriteCost = (stats.cacheWrite || 0) * (modelPricing.input || 0);
247
+
248
+ return inputCost + outputCost + cacheReadCost + cacheWriteCost;
249
+ }
250
+
251
+ function aggregateByTimeFrame(stats, timeKey, checkAsFree = true) {
252
+ const aggregated = {};
253
+
254
+ for (const row of stats) {
255
+ const key = row[timeKey];
256
+ if (!aggregated[key]) {
257
+ aggregated[key] = {
258
+ time: key,
259
+ inputTokens: 0,
260
+ outputTokens: 0,
261
+ cacheRead: 0,
262
+ cacheWrite: 0,
263
+ cost: 0,
264
+ messageCount: 0
265
+ };
266
+ }
267
+
268
+ aggregated[key].inputTokens += row.inputTokens || 0;
269
+ aggregated[key].outputTokens += row.outputTokens || 0;
270
+ aggregated[key].cacheRead += row.cacheRead || 0;
271
+ aggregated[key].cacheWrite += row.cacheWrite || 0;
272
+ aggregated[key].messageCount += row.messageCount || 0;
273
+ aggregated[key].cost += calculateCost(row, checkAsFree);
274
+ }
275
+
276
+ return Object.values(aggregated).sort((a, b) => a.time.localeCompare(b.time));
277
+ }
278
+
279
+ const app = express();
280
+ app.use(express.json());
281
+ app.get('/', (req, res) => {
282
+ res.sendFile(path.join(__dirname, '../public/index.html'));
283
+ });
284
+ app.get('/favicon.ico', (req, res) => res.status(204).end());
285
+ app.use(express.static(path.join(__dirname, '../public')));
286
+
287
+ app.get('/api/stats/overview', (req, res) => {
288
+ const checkAsFree = req.query.checkAsFree !== 'false';
289
+ const daysParam = req.query.days;
290
+ const days = daysParam !== undefined ? parseInt(daysParam) : null;
291
+ const overview = db.getOverview(days);
292
+ const modelsStats = days !== null && days > 0 ? db.getModelsStatsByDays(days) : db.getModelsStats();
293
+
294
+ let totalCost = 0;
295
+ for (const model of modelsStats) {
296
+ totalCost += calculateCost(model, checkAsFree);
297
+ }
298
+
299
+ res.json({
300
+ ...overview,
301
+ totalCost,
302
+ modelCount: modelsStats.length,
303
+ period: days ? `${days} days` : 'all'
304
+ });
305
+ });
306
+
307
+ app.get('/api/stats/models', (req, res) => {
308
+ const checkAsFree = req.query.checkAsFree !== 'false';
309
+ const days = req.query.days ? parseInt(req.query.days) : null;
310
+
311
+ const modelsStats = days ? db.getModelsStatsByDays(days) : db.getModelsStats();
312
+
313
+ const result = modelsStats.map(model => ({
314
+ ...model,
315
+ cost: calculateCost(model, checkAsFree)
316
+ }));
317
+
318
+ res.json(result);
319
+ });
320
+
321
+ app.get('/api/stats/daily', (req, res) => {
322
+ const checkAsFree = req.query.checkAsFree !== 'false';
323
+ const days = parseInt(req.query.days) || 30;
324
+ const stats = db.getDailyStats(days);
325
+ res.json(aggregateByTimeFrame(stats, 'date', checkAsFree));
326
+ });
327
+
328
+ app.get('/api/stats/daily/range', (req, res) => {
329
+ const checkAsFree = req.query.checkAsFree !== 'false';
330
+ const { start, end } = req.query;
331
+
332
+ if (!start || !end) {
333
+ return res.status(400).json({ error: 'Start and end dates required (YYYY-MM-DD format)' });
334
+ }
335
+
336
+ const startTs = new Date(start).getTime();
337
+ const endTs = new Date(end).getTime() + (24 * 60 * 60 * 1000 - 1);
338
+
339
+ const stats = db.getDailyStatsRange(startTs, endTs);
340
+ res.json(aggregateByTimeFrame(stats, 'date', checkAsFree));
341
+ });
342
+
343
+ app.get('/api/stats/weekly', (req, res) => {
344
+ const checkAsFree = req.query.checkAsFree !== 'false';
345
+ const weeks = parseInt(req.query.weeks) || 12;
346
+ const stats = db.getWeeklyStats(weeks);
347
+ res.json(aggregateByTimeFrame(stats, 'week', checkAsFree));
348
+ });
349
+
350
+ app.get('/api/stats/monthly', (req, res) => {
351
+ const checkAsFree = req.query.checkAsFree !== 'false';
352
+ const months = parseInt(req.query.months) || 12;
353
+ const stats = db.getMonthlyStats(months);
354
+ res.json(aggregateByTimeFrame(stats, 'month', checkAsFree));
355
+ });
356
+
357
+ app.get('/api/stats/hourly', (req, res) => {
358
+ const stats = db.getHourlyStats();
359
+ const result = [];
360
+
361
+ for (let i = 0; i < 24; i++) {
362
+ const found = stats.find(s => s.hour === i);
363
+ result.push({
364
+ hour: i,
365
+ messageCount: found?.messageCount || 0,
366
+ inputTokens: found?.inputTokens || 0,
367
+ outputTokens: found?.outputTokens || 0,
368
+ cacheRead: found?.cacheRead || 0,
369
+ cacheWrite: found?.cacheWrite || 0
370
+ });
371
+ }
372
+
373
+ res.json(result);
374
+ });
375
+
376
+ app.get('/api/stats/hourly-tps', (req, res) => {
377
+ const stats = db.getHourlyTPSStats();
378
+ res.json(stats);
379
+ });
380
+
381
+ app.get('/api/stats/models-tps', (req, res) => {
382
+ const days = req.query.days ? parseInt(req.query.days) : 30;
383
+ const stats = db.getModelsTPSStats(days);
384
+ res.json(stats);
385
+ });
386
+
387
+ app.get('/api/stats/daily-tps-by-model', (req, res) => {
388
+ const days = parseInt(req.query.days) || 30;
389
+ const modelsParam = req.query.models;
390
+ const modelFilter = modelsParam ? modelsParam.split(',').filter(Boolean) : null;
391
+ const data = db.getDailyTPSByModel(days, modelFilter);
392
+ res.json(data);
393
+ });
394
+
395
+ app.get('/api/models-list', (req, res) => {
396
+ const models = db.getModelsList();
397
+ res.json(models);
398
+ });
399
+
400
+ app.get('/api/pricing', (req, res) => {
401
+ res.json(pricing);
402
+ });
403
+
404
+ app.get('/api/pricing/models', (req, res) => {
405
+ const modelsStats = db.getModelsStats();
406
+ const allModels = { ...pricing.models };
407
+
408
+ const modelsWithUsage = modelsStats.map(m => {
409
+ const shortName = db.getShortModelName(m.baseModel);
410
+
411
+ let modelPricing = userPrices.prices && userPrices.prices[m.baseModel]
412
+ ? userPrices.prices[m.baseModel]
413
+ : (userPrices.prices && userPrices.prices[shortName]
414
+ ? userPrices.prices[shortName]
415
+ : (allModels[m.baseModel] || allModels[shortName] || { input: 0, output: 0, cacheRead: 0 }));
416
+
417
+ return {
418
+ name: m.baseModel,
419
+ messageCount: m.messageCount,
420
+ inputTokens: m.inputTokens,
421
+ pricing: modelPricing,
422
+ hasUserPrice: !!(userPrices.prices && (userPrices.prices[m.baseModel] || userPrices.prices[shortName])),
423
+ serverPricing: allModels[m.baseModel] || allModels[shortName] || null
424
+ };
425
+ });
426
+
427
+ modelsWithUsage.sort((a, b) => b.messageCount - a.messageCount);
428
+
429
+ res.json(modelsWithUsage);
430
+ });
431
+
432
+ app.put('/api/pricing', (req, res) => {
433
+ const { model, input, output, cacheRead } = req.body;
434
+
435
+ if (!model) {
436
+ return res.status(400).json({ error: 'Model name required' });
437
+ }
438
+
439
+ if (!userPrices.prices) userPrices.prices = {};
440
+
441
+ userPrices.prices[model] = {
442
+ input: (parseFloat(input) || 0) / 1000000,
443
+ output: (parseFloat(output) || 0) / 1000000,
444
+ cacheRead: (parseFloat(cacheRead) || 0) / 1000000
445
+ };
446
+
447
+ userPrices.lastUpdated = new Date().toISOString();
448
+ saveUserPrices();
449
+
450
+ res.json({ success: true, pricing: userPrices.prices[model] });
451
+ });
452
+
453
+ app.post('/api/pricing/refresh', async (req, res) => {
454
+ const success = await fetchModelsDevPricing();
455
+ if (success) {
456
+ res.json({ success: true, pricing });
457
+ } else {
458
+ res.status(500).json({ error: 'Failed to fetch pricing from models.dev' });
459
+ }
460
+ });
461
+
462
+ app.post('/api/pricing/reset', async (req, res) => {
463
+ userPrices = { lastUpdated: null, prices: {} };
464
+ saveUserPrices();
465
+
466
+ const success = await fetchModelsDevPricing();
467
+ if (success) {
468
+ res.json({ success: true, pricing, message: 'User prices cleared, server prices updated' });
469
+ } else {
470
+ res.json({ success: true, pricing, message: 'User prices cleared (failed to update server prices)' });
471
+ }
472
+ });
473
+
474
+ app.delete('/api/pricing/:model', (req, res) => {
475
+ const model = req.params.model;
476
+
477
+ if (pricing.overrides && pricing.overrides[model]) {
478
+ delete pricing.overrides[model];
479
+ savePricing();
480
+ res.json({ success: true });
481
+ } else {
482
+ res.status(404).json({ error: 'Override not found' });
483
+ }
484
+ });
485
+
486
+ const PORT = process.env.PORT || 3456;
487
+
488
+ app.listen(PORT, async () => {
489
+ console.log(`OpenCode Analytics running at http://localhost:${PORT}`);
490
+ await fetchModelsDevPricing();
491
+ });
@@ -0,0 +1,122 @@
1
+ {
2
+ "lastUpdated": "2026-03-17T10:05:06.696Z",
3
+ "source": "models.dev",
4
+ "overrides": {
5
+ "big-pickle": {
6
+ "input": 0.000001,
7
+ "output": 0.000001,
8
+ "cacheRead": 0.000001
9
+ },
10
+ "mimo-v2": {
11
+ "input": 0,
12
+ "output": 0,
13
+ "cacheRead": 0
14
+ },
15
+ "glm-5": {
16
+ "input": 7e-7,
17
+ "output": 0.0000023,
18
+ "cacheRead": 0
19
+ }
20
+ },
21
+ "models": {
22
+ "glm-5": {
23
+ "input": 7e-7,
24
+ "output": 0.0000023,
25
+ "cacheRead": 0
26
+ },
27
+ "glm-4.7": {
28
+ "input": 6e-7,
29
+ "output": 0.0000022,
30
+ "cacheRead": 1.1e-7,
31
+ "cacheWrite": 0,
32
+ "isFree": false
33
+ },
34
+ "glm-4.7-free": {
35
+ "input": 0,
36
+ "output": 0,
37
+ "cacheRead": 0,
38
+ "cacheWrite": 0,
39
+ "isFree": true
40
+ },
41
+ "kimi-k2.5-free": {
42
+ "input": 0,
43
+ "output": 0,
44
+ "cacheRead": 0,
45
+ "cacheWrite": 0,
46
+ "isFree": true
47
+ },
48
+ "minimax-m2.5-free": {
49
+ "input": 0,
50
+ "output": 0,
51
+ "cacheRead": 0,
52
+ "cacheWrite": 0,
53
+ "isFree": true
54
+ },
55
+ "grok-code": {
56
+ "input": 0,
57
+ "output": 0,
58
+ "cacheRead": 0,
59
+ "cacheWrite": 0,
60
+ "isFree": false
61
+ },
62
+ "big-pickle-free": {
63
+ "input": 0,
64
+ "output": 0,
65
+ "cacheRead": 0,
66
+ "cacheWrite": 0,
67
+ "isFree": true
68
+ },
69
+ "minimax-m2.1-free": {
70
+ "input": 0,
71
+ "output": 0,
72
+ "cacheRead": 0,
73
+ "cacheWrite": 0,
74
+ "isFree": true
75
+ },
76
+ "glm-4.7-flash": {
77
+ "input": 0,
78
+ "output": 0,
79
+ "cacheRead": 0,
80
+ "cacheWrite": 0,
81
+ "isFree": false
82
+ },
83
+ "mimo-v2-flash-free": {
84
+ "input": 0,
85
+ "output": 0,
86
+ "cacheRead": 0,
87
+ "cacheWrite": 0,
88
+ "isFree": true
89
+ },
90
+ "glm-5-free": {
91
+ "input": 0,
92
+ "output": 0,
93
+ "cacheRead": 0,
94
+ "cacheWrite": 0,
95
+ "isFree": true
96
+ },
97
+ "glm-4.5": {
98
+ "input": 6e-7,
99
+ "output": 0.0000022,
100
+ "cacheRead": 1.1e-7,
101
+ "cacheWrite": 0,
102
+ "isFree": false
103
+ },
104
+ "nemotron-3-super-free": {
105
+ "input": 0,
106
+ "output": 0,
107
+ "cacheRead": 0,
108
+ "cacheWrite": 0,
109
+ "isFree": true
110
+ },
111
+ "big-pickle": {
112
+ "input": 0.000001,
113
+ "output": 0.000001,
114
+ "cacheRead": 0.000001
115
+ },
116
+ "mimo-v2": {
117
+ "input": 0,
118
+ "output": 0,
119
+ "cacheRead": 0
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "lastUpdated": null,
3
+ "prices": {}
4
+ }