claude-usage-dashboard 1.3.4 → 1.3.6
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/bin/cli.js +2 -20
- package/package.json +40 -40
- package/public/css/style.css +265 -265
- package/public/index.html +108 -108
- package/public/js/api.js +16 -16
- package/public/js/app.js +273 -273
- package/public/js/charts/cache-efficiency.js +29 -29
- package/public/js/charts/cost-comparison.js +39 -39
- package/public/js/charts/model-distribution.js +56 -56
- package/public/js/charts/project-distribution.js +103 -103
- package/public/js/charts/session-stats.js +117 -117
- package/public/js/charts/token-trend.js +357 -357
- package/public/js/components/date-picker.js +35 -35
- package/public/js/components/plan-selector.js +57 -57
- package/server/aggregator.js +147 -147
- package/server/index.js +33 -33
- package/server/parser.js +109 -109
- package/server/pricing.js +52 -52
- package/server/routes/api.js +127 -127
package/server/parser.js
CHANGED
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
|
|
4
|
-
export function deriveProjectName(dirName) {
|
|
5
|
-
// Strip drive prefix like "C--" at the start
|
|
6
|
-
const clean = dirName.replace(/^[A-Za-z]--/, '');
|
|
7
|
-
|
|
8
|
-
// Known parent directory markers (case-insensitive search)
|
|
9
|
-
// Match the last occurrence of common parent dirs to get the project folder name
|
|
10
|
-
const lower = clean.toLowerCase();
|
|
11
|
-
const markers = ['-workspace-', '-projects-', '-repos-', '-src-', '-home-', '-desktop-', '-documents-', '-downloads-'];
|
|
12
|
-
let bestIdx = -1;
|
|
13
|
-
let bestLen = 0;
|
|
14
|
-
for (const m of markers) {
|
|
15
|
-
const idx = lower.lastIndexOf(m);
|
|
16
|
-
if (idx > bestIdx) {
|
|
17
|
-
bestIdx = idx;
|
|
18
|
-
bestLen = m.length;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
if (bestIdx !== -1) {
|
|
22
|
-
const result = clean.slice(bestIdx + bestLen);
|
|
23
|
-
// Handle worktree subdirs: "project--claude-worktrees-branch-name" → "project"
|
|
24
|
-
const wtIdx = result.indexOf('--claude-worktrees');
|
|
25
|
-
return wtIdx !== -1 ? result.slice(0, wtIdx) : result;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Fallback: strip Users-username prefix, return the rest
|
|
29
|
-
const userMatch = clean.match(/^Users-[^-]+-(.+)$/);
|
|
30
|
-
if (userMatch) {
|
|
31
|
-
const rest = userMatch[1];
|
|
32
|
-
const wtIdx = rest.indexOf('--claude-worktrees');
|
|
33
|
-
return wtIdx !== -1 ? rest.slice(0, wtIdx) : rest;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return clean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function parseLogFile(filePath) {
|
|
40
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
41
|
-
const lines = content.split('\n').filter(line => line.trim());
|
|
42
|
-
const records = [];
|
|
43
|
-
|
|
44
|
-
for (const line of lines) {
|
|
45
|
-
let entry;
|
|
46
|
-
try {
|
|
47
|
-
entry = JSON.parse(line);
|
|
48
|
-
} catch {
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (entry.type !== 'assistant') continue;
|
|
53
|
-
|
|
54
|
-
const model = entry.message?.model;
|
|
55
|
-
if (!model || model === '<synthetic>') continue;
|
|
56
|
-
|
|
57
|
-
const usage = entry.message?.usage;
|
|
58
|
-
if (!usage) continue;
|
|
59
|
-
|
|
60
|
-
records.push({
|
|
61
|
-
sessionId: entry.sessionId,
|
|
62
|
-
timestamp: entry.timestamp,
|
|
63
|
-
model,
|
|
64
|
-
input_tokens: usage.input_tokens || 0,
|
|
65
|
-
output_tokens: usage.output_tokens || 0,
|
|
66
|
-
cache_creation_tokens: usage.cache_creation_input_tokens || 0,
|
|
67
|
-
cache_read_tokens: usage.cache_read_input_tokens || 0,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return records;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function parseLogDirectory(baseDir) {
|
|
75
|
-
const allRecords = [];
|
|
76
|
-
|
|
77
|
-
let projectDirs;
|
|
78
|
-
try {
|
|
79
|
-
projectDirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
|
80
|
-
.filter(d => d.isDirectory());
|
|
81
|
-
} catch {
|
|
82
|
-
return allRecords;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
for (const dir of projectDirs) {
|
|
86
|
-
const projectName = deriveProjectName(dir.name);
|
|
87
|
-
const projectPath = path.join(baseDir, dir.name);
|
|
88
|
-
|
|
89
|
-
let files;
|
|
90
|
-
try {
|
|
91
|
-
files = fs.readdirSync(projectPath)
|
|
92
|
-
.filter(f => f.endsWith('.jsonl'));
|
|
93
|
-
} catch {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
for (const file of files) {
|
|
98
|
-
const filePath = path.join(projectPath, file);
|
|
99
|
-
const records = parseLogFile(filePath);
|
|
100
|
-
for (const record of records) {
|
|
101
|
-
record.project = projectName;
|
|
102
|
-
record.projectDirName = dir.name;
|
|
103
|
-
}
|
|
104
|
-
allRecords.push(...records);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return allRecords;
|
|
109
|
-
}
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export function deriveProjectName(dirName) {
|
|
5
|
+
// Strip drive prefix like "C--" at the start
|
|
6
|
+
const clean = dirName.replace(/^[A-Za-z]--/, '');
|
|
7
|
+
|
|
8
|
+
// Known parent directory markers (case-insensitive search)
|
|
9
|
+
// Match the last occurrence of common parent dirs to get the project folder name
|
|
10
|
+
const lower = clean.toLowerCase();
|
|
11
|
+
const markers = ['-workspace-', '-projects-', '-repos-', '-src-', '-home-', '-desktop-', '-documents-', '-downloads-'];
|
|
12
|
+
let bestIdx = -1;
|
|
13
|
+
let bestLen = 0;
|
|
14
|
+
for (const m of markers) {
|
|
15
|
+
const idx = lower.lastIndexOf(m);
|
|
16
|
+
if (idx > bestIdx) {
|
|
17
|
+
bestIdx = idx;
|
|
18
|
+
bestLen = m.length;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (bestIdx !== -1) {
|
|
22
|
+
const result = clean.slice(bestIdx + bestLen);
|
|
23
|
+
// Handle worktree subdirs: "project--claude-worktrees-branch-name" → "project"
|
|
24
|
+
const wtIdx = result.indexOf('--claude-worktrees');
|
|
25
|
+
return wtIdx !== -1 ? result.slice(0, wtIdx) : result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fallback: strip Users-username prefix, return the rest
|
|
29
|
+
const userMatch = clean.match(/^Users-[^-]+-(.+)$/);
|
|
30
|
+
if (userMatch) {
|
|
31
|
+
const rest = userMatch[1];
|
|
32
|
+
const wtIdx = rest.indexOf('--claude-worktrees');
|
|
33
|
+
return wtIdx !== -1 ? rest.slice(0, wtIdx) : rest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return clean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseLogFile(filePath) {
|
|
40
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
41
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
42
|
+
const records = [];
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
let entry;
|
|
46
|
+
try {
|
|
47
|
+
entry = JSON.parse(line);
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (entry.type !== 'assistant') continue;
|
|
53
|
+
|
|
54
|
+
const model = entry.message?.model;
|
|
55
|
+
if (!model || model === '<synthetic>') continue;
|
|
56
|
+
|
|
57
|
+
const usage = entry.message?.usage;
|
|
58
|
+
if (!usage) continue;
|
|
59
|
+
|
|
60
|
+
records.push({
|
|
61
|
+
sessionId: entry.sessionId,
|
|
62
|
+
timestamp: entry.timestamp,
|
|
63
|
+
model,
|
|
64
|
+
input_tokens: usage.input_tokens || 0,
|
|
65
|
+
output_tokens: usage.output_tokens || 0,
|
|
66
|
+
cache_creation_tokens: usage.cache_creation_input_tokens || 0,
|
|
67
|
+
cache_read_tokens: usage.cache_read_input_tokens || 0,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return records;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseLogDirectory(baseDir) {
|
|
75
|
+
const allRecords = [];
|
|
76
|
+
|
|
77
|
+
let projectDirs;
|
|
78
|
+
try {
|
|
79
|
+
projectDirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
|
80
|
+
.filter(d => d.isDirectory());
|
|
81
|
+
} catch {
|
|
82
|
+
return allRecords;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const dir of projectDirs) {
|
|
86
|
+
const projectName = deriveProjectName(dir.name);
|
|
87
|
+
const projectPath = path.join(baseDir, dir.name);
|
|
88
|
+
|
|
89
|
+
let files;
|
|
90
|
+
try {
|
|
91
|
+
files = fs.readdirSync(projectPath)
|
|
92
|
+
.filter(f => f.endsWith('.jsonl'));
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const filePath = path.join(projectPath, file);
|
|
99
|
+
const records = parseLogFile(filePath);
|
|
100
|
+
for (const record of records) {
|
|
101
|
+
record.project = projectName;
|
|
102
|
+
record.projectDirName = dir.name;
|
|
103
|
+
}
|
|
104
|
+
allRecords.push(...records);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return allRecords;
|
|
109
|
+
}
|
package/server/pricing.js
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
export const MODEL_PRICING = {
|
|
2
|
-
'claude-opus-4-6': {
|
|
3
|
-
input_price_per_mtok: 5,
|
|
4
|
-
output_price_per_mtok: 25,
|
|
5
|
-
cache_read_price_per_mtok: 0.50,
|
|
6
|
-
cache_creation_price_per_mtok: 6.25,
|
|
7
|
-
},
|
|
8
|
-
'claude-sonnet-4-6': {
|
|
9
|
-
input_price_per_mtok: 3,
|
|
10
|
-
output_price_per_mtok: 15,
|
|
11
|
-
cache_read_price_per_mtok: 0.30,
|
|
12
|
-
cache_creation_price_per_mtok: 3.75,
|
|
13
|
-
},
|
|
14
|
-
'claude-haiku-4-5': {
|
|
15
|
-
input_price_per_mtok: 1,
|
|
16
|
-
output_price_per_mtok: 5,
|
|
17
|
-
cache_read_price_per_mtok: 0.10,
|
|
18
|
-
cache_creation_price_per_mtok: 1.25,
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export const PLAN_DEFAULTS = {
|
|
23
|
-
pro: 20,
|
|
24
|
-
max5x: 100,
|
|
25
|
-
max20x: 200,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export function getModelPricing(modelId) {
|
|
29
|
-
return MODEL_PRICING[modelId] || null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Calculate the API cost for a single usage record.
|
|
34
|
-
* Returns 0 for unknown models.
|
|
35
|
-
*
|
|
36
|
-
* In Claude Code logs, input_tokens is the non-cached input.
|
|
37
|
-
* cache_read_tokens and cache_creation_tokens are separate, additive fields.
|
|
38
|
-
* cost = input * input_rate + cache_read * read_rate + cache_creation * write_rate + output * output_rate
|
|
39
|
-
*/
|
|
40
|
-
export function calculateRecordCost(record) {
|
|
41
|
-
const pricing = MODEL_PRICING[record.model];
|
|
42
|
-
if (!pricing) return 0;
|
|
43
|
-
|
|
44
|
-
const M = 1_000_000;
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
(record.input_tokens / M) * pricing.input_price_per_mtok +
|
|
48
|
-
(record.cache_read_tokens / M) * pricing.cache_read_price_per_mtok +
|
|
49
|
-
(record.cache_creation_tokens / M) * pricing.cache_creation_price_per_mtok +
|
|
50
|
-
(record.output_tokens / M) * pricing.output_price_per_mtok
|
|
51
|
-
);
|
|
52
|
-
}
|
|
1
|
+
export const MODEL_PRICING = {
|
|
2
|
+
'claude-opus-4-6': {
|
|
3
|
+
input_price_per_mtok: 5,
|
|
4
|
+
output_price_per_mtok: 25,
|
|
5
|
+
cache_read_price_per_mtok: 0.50,
|
|
6
|
+
cache_creation_price_per_mtok: 6.25,
|
|
7
|
+
},
|
|
8
|
+
'claude-sonnet-4-6': {
|
|
9
|
+
input_price_per_mtok: 3,
|
|
10
|
+
output_price_per_mtok: 15,
|
|
11
|
+
cache_read_price_per_mtok: 0.30,
|
|
12
|
+
cache_creation_price_per_mtok: 3.75,
|
|
13
|
+
},
|
|
14
|
+
'claude-haiku-4-5': {
|
|
15
|
+
input_price_per_mtok: 1,
|
|
16
|
+
output_price_per_mtok: 5,
|
|
17
|
+
cache_read_price_per_mtok: 0.10,
|
|
18
|
+
cache_creation_price_per_mtok: 1.25,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const PLAN_DEFAULTS = {
|
|
23
|
+
pro: 20,
|
|
24
|
+
max5x: 100,
|
|
25
|
+
max20x: 200,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function getModelPricing(modelId) {
|
|
29
|
+
return MODEL_PRICING[modelId] || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Calculate the API cost for a single usage record.
|
|
34
|
+
* Returns 0 for unknown models.
|
|
35
|
+
*
|
|
36
|
+
* In Claude Code logs, input_tokens is the non-cached input.
|
|
37
|
+
* cache_read_tokens and cache_creation_tokens are separate, additive fields.
|
|
38
|
+
* cost = input * input_rate + cache_read * read_rate + cache_creation * write_rate + output * output_rate
|
|
39
|
+
*/
|
|
40
|
+
export function calculateRecordCost(record) {
|
|
41
|
+
const pricing = MODEL_PRICING[record.model];
|
|
42
|
+
if (!pricing) return 0;
|
|
43
|
+
|
|
44
|
+
const M = 1_000_000;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
(record.input_tokens / M) * pricing.input_price_per_mtok +
|
|
48
|
+
(record.cache_read_tokens / M) * pricing.cache_read_price_per_mtok +
|
|
49
|
+
(record.cache_creation_tokens / M) * pricing.cache_creation_price_per_mtok +
|
|
50
|
+
(record.output_tokens / M) * pricing.output_price_per_mtok
|
|
51
|
+
);
|
|
52
|
+
}
|
package/server/routes/api.js
CHANGED
|
@@ -1,127 +1,127 @@
|
|
|
1
|
-
import { Router } from 'express';
|
|
2
|
-
import { parseLogDirectory } from '../parser.js';
|
|
3
|
-
import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
|
|
4
|
-
import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
|
|
5
|
-
import { createQuotaFetcher } from '../quota.js';
|
|
6
|
-
import { getSubscriptionInfo } from '../credentials.js';
|
|
7
|
-
|
|
8
|
-
export function createApiRouter(logBaseDir, options = {}) {
|
|
9
|
-
const router = Router();
|
|
10
|
-
const CACHE_TTL_MS = options.cacheTtlMs || 5000;
|
|
11
|
-
let cachedRecords = [];
|
|
12
|
-
let lastRefreshed = null;
|
|
13
|
-
|
|
14
|
-
function refreshRecords() {
|
|
15
|
-
const now = Date.now();
|
|
16
|
-
if (lastRefreshed && (now - lastRefreshed) < CACHE_TTL_MS) return cachedRecords;
|
|
17
|
-
try {
|
|
18
|
-
cachedRecords = parseLogDirectory(logBaseDir);
|
|
19
|
-
lastRefreshed = now;
|
|
20
|
-
console.log(`Parsed ${cachedRecords.length} records from ${logBaseDir}`);
|
|
21
|
-
} catch (err) {
|
|
22
|
-
console.error('Failed to parse log directory:', err.message);
|
|
23
|
-
// Keep stale data on failure
|
|
24
|
-
if (!lastRefreshed) lastRefreshed = now;
|
|
25
|
-
}
|
|
26
|
-
return cachedRecords;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Initial parse
|
|
30
|
-
refreshRecords();
|
|
31
|
-
|
|
32
|
-
function applyFilters(query) {
|
|
33
|
-
let records = filterByDateRange(refreshRecords(), query.from, query.to);
|
|
34
|
-
if (query.project) records = records.filter(r => r.project === query.project);
|
|
35
|
-
if (query.model) records = records.filter(r => r.model === query.model);
|
|
36
|
-
return records;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
router.get('/usage', (req, res) => {
|
|
40
|
-
try {
|
|
41
|
-
const records = applyFilters(req.query);
|
|
42
|
-
const granularity = req.query.granularity || autoGranularity(req.query.from, req.query.to);
|
|
43
|
-
const buckets = aggregateByTime(records, granularity);
|
|
44
|
-
const total = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_api_cost_usd: 0 };
|
|
45
|
-
for (const r of records) {
|
|
46
|
-
total.input_tokens += r.input_tokens; total.output_tokens += r.output_tokens;
|
|
47
|
-
total.cache_read_tokens += r.cache_read_tokens; total.cache_creation_tokens += r.cache_creation_tokens;
|
|
48
|
-
total.estimated_api_cost_usd += calculateRecordCost(r);
|
|
49
|
-
}
|
|
50
|
-
total.estimated_api_cost_usd = Math.round(total.estimated_api_cost_usd * 100) / 100;
|
|
51
|
-
res.json({ granularity, buckets, total });
|
|
52
|
-
} catch (err) {
|
|
53
|
-
res.status(500).json({ error: err.message, code: 'PARSE_ERROR' });
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
router.get('/models', (req, res) => { res.json({ models: aggregateByModel(applyFilters(req.query)) }); });
|
|
58
|
-
router.get('/projects', (req, res) => { res.json({ projects: aggregateByProject(applyFilters(req.query)) }); });
|
|
59
|
-
|
|
60
|
-
router.get('/sessions', (req, res) => {
|
|
61
|
-
const records = applyFilters(req.query);
|
|
62
|
-
let sessions = aggregateBySession(records);
|
|
63
|
-
const sort = req.query.sort || 'date';
|
|
64
|
-
const order = req.query.order || 'desc';
|
|
65
|
-
const sortFn = { date: (a, b) => new Date(b.startTime) - new Date(a.startTime), cost: (a, b) => b.estimated_cost_usd - a.estimated_cost_usd, tokens: (a, b) => b.total_tokens - a.total_tokens }[sort] || ((a, b) => new Date(b.startTime) - new Date(a.startTime));
|
|
66
|
-
sessions.sort(sortFn);
|
|
67
|
-
if (order === 'asc') sessions.reverse();
|
|
68
|
-
const totalTokens = sessions.reduce((sum, s) => sum + s.total_tokens, 0);
|
|
69
|
-
const totalCost = sessions.reduce((sum, s) => sum + s.estimated_cost_usd, 0);
|
|
70
|
-
const page = parseInt(req.query.page) || 1;
|
|
71
|
-
const limit = parseInt(req.query.limit) || 20;
|
|
72
|
-
const totalSessions = sessions.length;
|
|
73
|
-
const totalPages = Math.ceil(totalSessions / limit);
|
|
74
|
-
sessions = sessions.slice((page - 1) * limit, page * limit);
|
|
75
|
-
res.json({ sessions, pagination: { page, limit, total_sessions: totalSessions, total_pages: totalPages }, totals: { total_tokens: totalTokens, estimated_cost_usd: Math.round(totalCost * 100) / 100 } });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
router.get('/cost', (req, res) => {
|
|
79
|
-
const records = applyFilters(req.query);
|
|
80
|
-
const plan = req.query.plan || 'max5x';
|
|
81
|
-
const customPrice = req.query.customPrice ? parseFloat(req.query.customPrice) : null;
|
|
82
|
-
const subscriptionCost = customPrice || PLAN_DEFAULTS[plan] || 100;
|
|
83
|
-
let apiCost = 0;
|
|
84
|
-
for (const r of records) apiCost += calculateRecordCost(r);
|
|
85
|
-
apiCost = Math.round(apiCost * 100) / 100;
|
|
86
|
-
const dayMap = new Map();
|
|
87
|
-
for (const r of records) {
|
|
88
|
-
const d = new Date(r.timestamp);
|
|
89
|
-
const day = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
90
|
-
dayMap.set(day, (dayMap.get(day) || 0) + calculateRecordCost(r));
|
|
91
|
-
}
|
|
92
|
-
const costPerDay = Array.from(dayMap.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([date, cost]) => {
|
|
93
|
-
const d = new Date(date);
|
|
94
|
-
const daysInMonth = new Date(d.getUTCFullYear(), d.getUTCMonth() + 1, 0).getUTCDate();
|
|
95
|
-
return { date, api_cost: Math.round(cost * 100) / 100, subscription_daily: Math.round((subscriptionCost / daysInMonth) * 100) / 100 };
|
|
96
|
-
});
|
|
97
|
-
const savings = apiCost - subscriptionCost;
|
|
98
|
-
res.json({ plan, subscription_cost_usd: subscriptionCost, api_equivalent_cost_usd: apiCost, savings_usd: Math.round(savings * 100) / 100, savings_percent: apiCost > 0 ? Math.round((savings / apiCost) * 1000) / 10 : 0, cost_per_day: costPerDay });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
router.get('/cache', (req, res) => { res.json(aggregateCache(applyFilters(req.query))); });
|
|
102
|
-
|
|
103
|
-
const quotaFetcher = options.quotaFetcher || createQuotaFetcher();
|
|
104
|
-
router.get('/quota', async (req, res) => {
|
|
105
|
-
try {
|
|
106
|
-
const data = await quotaFetcher.fetchQuota();
|
|
107
|
-
res.json(data);
|
|
108
|
-
} catch (err) {
|
|
109
|
-
res.json({ available: false, error: err.message });
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
router.get('/subscription', (req, res) => {
|
|
114
|
-
const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
|
|
115
|
-
res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
router.get('/status', (req, res) => {
|
|
119
|
-
res.json({
|
|
120
|
-
record_count: cachedRecords.length,
|
|
121
|
-
last_refreshed: lastRefreshed ? new Date(lastRefreshed).toISOString() : null,
|
|
122
|
-
cache_ttl_ms: CACHE_TTL_MS,
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
return router;
|
|
127
|
-
}
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { parseLogDirectory } from '../parser.js';
|
|
3
|
+
import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
|
|
4
|
+
import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
|
|
5
|
+
import { createQuotaFetcher } from '../quota.js';
|
|
6
|
+
import { getSubscriptionInfo } from '../credentials.js';
|
|
7
|
+
|
|
8
|
+
export function createApiRouter(logBaseDir, options = {}) {
|
|
9
|
+
const router = Router();
|
|
10
|
+
const CACHE_TTL_MS = options.cacheTtlMs || 5000;
|
|
11
|
+
let cachedRecords = [];
|
|
12
|
+
let lastRefreshed = null;
|
|
13
|
+
|
|
14
|
+
function refreshRecords() {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (lastRefreshed && (now - lastRefreshed) < CACHE_TTL_MS) return cachedRecords;
|
|
17
|
+
try {
|
|
18
|
+
cachedRecords = parseLogDirectory(logBaseDir);
|
|
19
|
+
lastRefreshed = now;
|
|
20
|
+
console.log(`Parsed ${cachedRecords.length} records from ${logBaseDir}`);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error('Failed to parse log directory:', err.message);
|
|
23
|
+
// Keep stale data on failure
|
|
24
|
+
if (!lastRefreshed) lastRefreshed = now;
|
|
25
|
+
}
|
|
26
|
+
return cachedRecords;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Initial parse
|
|
30
|
+
refreshRecords();
|
|
31
|
+
|
|
32
|
+
function applyFilters(query) {
|
|
33
|
+
let records = filterByDateRange(refreshRecords(), query.from, query.to);
|
|
34
|
+
if (query.project) records = records.filter(r => r.project === query.project);
|
|
35
|
+
if (query.model) records = records.filter(r => r.model === query.model);
|
|
36
|
+
return records;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
router.get('/usage', (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const records = applyFilters(req.query);
|
|
42
|
+
const granularity = req.query.granularity || autoGranularity(req.query.from, req.query.to);
|
|
43
|
+
const buckets = aggregateByTime(records, granularity);
|
|
44
|
+
const total = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0, estimated_api_cost_usd: 0 };
|
|
45
|
+
for (const r of records) {
|
|
46
|
+
total.input_tokens += r.input_tokens; total.output_tokens += r.output_tokens;
|
|
47
|
+
total.cache_read_tokens += r.cache_read_tokens; total.cache_creation_tokens += r.cache_creation_tokens;
|
|
48
|
+
total.estimated_api_cost_usd += calculateRecordCost(r);
|
|
49
|
+
}
|
|
50
|
+
total.estimated_api_cost_usd = Math.round(total.estimated_api_cost_usd * 100) / 100;
|
|
51
|
+
res.json({ granularity, buckets, total });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
res.status(500).json({ error: err.message, code: 'PARSE_ERROR' });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
router.get('/models', (req, res) => { res.json({ models: aggregateByModel(applyFilters(req.query)) }); });
|
|
58
|
+
router.get('/projects', (req, res) => { res.json({ projects: aggregateByProject(applyFilters(req.query)) }); });
|
|
59
|
+
|
|
60
|
+
router.get('/sessions', (req, res) => {
|
|
61
|
+
const records = applyFilters(req.query);
|
|
62
|
+
let sessions = aggregateBySession(records);
|
|
63
|
+
const sort = req.query.sort || 'date';
|
|
64
|
+
const order = req.query.order || 'desc';
|
|
65
|
+
const sortFn = { date: (a, b) => new Date(b.startTime) - new Date(a.startTime), cost: (a, b) => b.estimated_cost_usd - a.estimated_cost_usd, tokens: (a, b) => b.total_tokens - a.total_tokens }[sort] || ((a, b) => new Date(b.startTime) - new Date(a.startTime));
|
|
66
|
+
sessions.sort(sortFn);
|
|
67
|
+
if (order === 'asc') sessions.reverse();
|
|
68
|
+
const totalTokens = sessions.reduce((sum, s) => sum + s.total_tokens, 0);
|
|
69
|
+
const totalCost = sessions.reduce((sum, s) => sum + s.estimated_cost_usd, 0);
|
|
70
|
+
const page = parseInt(req.query.page) || 1;
|
|
71
|
+
const limit = parseInt(req.query.limit) || 20;
|
|
72
|
+
const totalSessions = sessions.length;
|
|
73
|
+
const totalPages = Math.ceil(totalSessions / limit);
|
|
74
|
+
sessions = sessions.slice((page - 1) * limit, page * limit);
|
|
75
|
+
res.json({ sessions, pagination: { page, limit, total_sessions: totalSessions, total_pages: totalPages }, totals: { total_tokens: totalTokens, estimated_cost_usd: Math.round(totalCost * 100) / 100 } });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
router.get('/cost', (req, res) => {
|
|
79
|
+
const records = applyFilters(req.query);
|
|
80
|
+
const plan = req.query.plan || 'max5x';
|
|
81
|
+
const customPrice = req.query.customPrice ? parseFloat(req.query.customPrice) : null;
|
|
82
|
+
const subscriptionCost = customPrice || PLAN_DEFAULTS[plan] || 100;
|
|
83
|
+
let apiCost = 0;
|
|
84
|
+
for (const r of records) apiCost += calculateRecordCost(r);
|
|
85
|
+
apiCost = Math.round(apiCost * 100) / 100;
|
|
86
|
+
const dayMap = new Map();
|
|
87
|
+
for (const r of records) {
|
|
88
|
+
const d = new Date(r.timestamp);
|
|
89
|
+
const day = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
90
|
+
dayMap.set(day, (dayMap.get(day) || 0) + calculateRecordCost(r));
|
|
91
|
+
}
|
|
92
|
+
const costPerDay = Array.from(dayMap.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([date, cost]) => {
|
|
93
|
+
const d = new Date(date);
|
|
94
|
+
const daysInMonth = new Date(d.getUTCFullYear(), d.getUTCMonth() + 1, 0).getUTCDate();
|
|
95
|
+
return { date, api_cost: Math.round(cost * 100) / 100, subscription_daily: Math.round((subscriptionCost / daysInMonth) * 100) / 100 };
|
|
96
|
+
});
|
|
97
|
+
const savings = apiCost - subscriptionCost;
|
|
98
|
+
res.json({ plan, subscription_cost_usd: subscriptionCost, api_equivalent_cost_usd: apiCost, savings_usd: Math.round(savings * 100) / 100, savings_percent: apiCost > 0 ? Math.round((savings / apiCost) * 1000) / 10 : 0, cost_per_day: costPerDay });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.get('/cache', (req, res) => { res.json(aggregateCache(applyFilters(req.query))); });
|
|
102
|
+
|
|
103
|
+
const quotaFetcher = options.quotaFetcher || createQuotaFetcher();
|
|
104
|
+
router.get('/quota', async (req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
const data = await quotaFetcher.fetchQuota();
|
|
107
|
+
res.json(data);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
res.json({ available: false, error: err.message });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
router.get('/subscription', (req, res) => {
|
|
114
|
+
const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
|
|
115
|
+
res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
router.get('/status', (req, res) => {
|
|
119
|
+
res.json({
|
|
120
|
+
record_count: cachedRecords.length,
|
|
121
|
+
last_refreshed: lastRefreshed ? new Date(lastRefreshed).toISOString() : null,
|
|
122
|
+
cache_ttl_ms: CACHE_TTL_MS,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return router;
|
|
127
|
+
}
|