@ian2018cs/agenthub 0.1.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/LICENSE +675 -0
- package/README.md +330 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/index-B4ru3EJb.css +32 -0
- package/dist/assets/index-DDFuyrpY.js +154 -0
- package/dist/assets/vendor-codemirror-C_VWDoZS.js +39 -0
- package/dist/assets/vendor-icons-CJV4dnDL.js +326 -0
- package/dist/assets/vendor-katex-DK8hFnhL.js +261 -0
- package/dist/assets/vendor-markdown-VwNYkg_0.js +35 -0
- package/dist/assets/vendor-react-BeVl62c0.js +59 -0
- package/dist/assets/vendor-syntax-CdGaPJRS.js +16 -0
- package/dist/assets/vendor-utils-00TdZexr.js +1 -0
- package/dist/assets/vendor-xterm-CvdiG4-n.js +66 -0
- package/dist/clear-cache.html +85 -0
- package/dist/convert-icons.md +53 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +9 -0
- package/dist/generate-icons.js +49 -0
- package/dist/icons/claude-ai-icon.svg +1 -0
- package/dist/icons/codex-white.svg +3 -0
- package/dist/icons/codex.svg +3 -0
- package/dist/icons/cursor-white.svg +12 -0
- package/dist/icons/cursor.svg +1 -0
- package/dist/icons/generate-icons.md +19 -0
- package/dist/icons/icon-128x128.png +0 -0
- package/dist/icons/icon-128x128.svg +12 -0
- package/dist/icons/icon-144x144.png +0 -0
- package/dist/icons/icon-144x144.svg +12 -0
- package/dist/icons/icon-152x152.png +0 -0
- package/dist/icons/icon-152x152.svg +12 -0
- package/dist/icons/icon-192x192.png +0 -0
- package/dist/icons/icon-192x192.svg +12 -0
- package/dist/icons/icon-384x384.png +0 -0
- package/dist/icons/icon-384x384.svg +12 -0
- package/dist/icons/icon-512x512.png +0 -0
- package/dist/icons/icon-512x512.svg +12 -0
- package/dist/icons/icon-72x72.png +0 -0
- package/dist/icons/icon-72x72.svg +12 -0
- package/dist/icons/icon-96x96.png +0 -0
- package/dist/icons/icon-96x96.svg +12 -0
- package/dist/icons/icon-template.svg +12 -0
- package/dist/index.html +57 -0
- package/dist/logo-128.png +0 -0
- package/dist/logo-256.png +0 -0
- package/dist/logo-32.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-64.png +0 -0
- package/dist/logo.svg +17 -0
- package/dist/manifest.json +61 -0
- package/dist/screenshots/cli-selection.png +0 -0
- package/dist/screenshots/desktop-main.png +0 -0
- package/dist/screenshots/mobile-chat.png +0 -0
- package/dist/screenshots/tools-modal.png +0 -0
- package/dist/sw.js +49 -0
- package/package.json +113 -0
- package/server/claude-sdk.js +791 -0
- package/server/cli.js +330 -0
- package/server/database/auth.db +0 -0
- package/server/database/db.js +523 -0
- package/server/database/init.sql +23 -0
- package/server/index.js +1678 -0
- package/server/load-env.js +27 -0
- package/server/middleware/auth.js +118 -0
- package/server/projects.js +899 -0
- package/server/routes/admin.js +89 -0
- package/server/routes/auth.js +144 -0
- package/server/routes/commands.js +570 -0
- package/server/routes/mcp-utils.js +37 -0
- package/server/routes/mcp.js +593 -0
- package/server/routes/projects.js +216 -0
- package/server/routes/skills.js +891 -0
- package/server/routes/usage.js +206 -0
- package/server/services/pricing.js +196 -0
- package/server/services/usage-scanner.js +283 -0
- package/server/services/user-directories.js +123 -0
- package/server/utils/commandParser.js +303 -0
- package/server/utils/mcp-detector.js +73 -0
- package/shared/modelConstants.js +23 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { userDb, usageDb } from '../database/db.js';
|
|
3
|
+
import { authenticateToken } from '../middleware/auth.js';
|
|
4
|
+
import { triggerManualScan } from '../services/usage-scanner.js';
|
|
5
|
+
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
|
|
8
|
+
// Admin middleware
|
|
9
|
+
const requireAdmin = (req, res, next) => {
|
|
10
|
+
if (req.user.role !== 'admin') {
|
|
11
|
+
return res.status(403).json({ error: 'Admin access required' });
|
|
12
|
+
}
|
|
13
|
+
next();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Apply auth and admin middleware to all routes
|
|
17
|
+
router.use(authenticateToken);
|
|
18
|
+
router.use(requireAdmin);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get usage summary for all users
|
|
22
|
+
* Used in the user list page to show cost per user
|
|
23
|
+
*/
|
|
24
|
+
router.get('/summary', (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const usageSummary = usageDb.getAllUsersSummary();
|
|
27
|
+
const users = userDb.getAllUsers();
|
|
28
|
+
|
|
29
|
+
// Create a map of uuid to user info
|
|
30
|
+
const userMap = {};
|
|
31
|
+
for (const user of users) {
|
|
32
|
+
userMap[user.uuid] = {
|
|
33
|
+
id: user.id,
|
|
34
|
+
username: user.username,
|
|
35
|
+
role: user.role,
|
|
36
|
+
status: user.status
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Merge usage data with user info
|
|
41
|
+
const result = usageSummary.map(usage => ({
|
|
42
|
+
...usage,
|
|
43
|
+
...userMap[usage.user_uuid]
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Add users with no usage
|
|
47
|
+
for (const user of users) {
|
|
48
|
+
if (!usageSummary.find(u => u.user_uuid === user.uuid)) {
|
|
49
|
+
result.push({
|
|
50
|
+
user_uuid: user.uuid,
|
|
51
|
+
id: user.id,
|
|
52
|
+
username: user.username,
|
|
53
|
+
role: user.role,
|
|
54
|
+
status: user.status,
|
|
55
|
+
total_cost: 0,
|
|
56
|
+
total_requests: 0,
|
|
57
|
+
total_sessions: 0,
|
|
58
|
+
last_active: null
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
res.json({ users: result });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('Error fetching usage summary:', error);
|
|
66
|
+
res.status(500).json({ error: 'Failed to fetch usage summary' });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get detailed usage for a specific user
|
|
72
|
+
*/
|
|
73
|
+
router.get('/users/:uuid', (req, res) => {
|
|
74
|
+
try {
|
|
75
|
+
const { uuid } = req.params;
|
|
76
|
+
const { period = 'week' } = req.query;
|
|
77
|
+
|
|
78
|
+
// Validate user exists
|
|
79
|
+
const user = userDb.getUserByUuid(uuid);
|
|
80
|
+
if (!user) {
|
|
81
|
+
return res.status(404).json({ error: 'User not found' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Calculate date range
|
|
85
|
+
const endDate = new Date().toISOString().split('T')[0];
|
|
86
|
+
let startDate;
|
|
87
|
+
|
|
88
|
+
switch (period) {
|
|
89
|
+
case 'week':
|
|
90
|
+
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
91
|
+
break;
|
|
92
|
+
case 'month':
|
|
93
|
+
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
94
|
+
break;
|
|
95
|
+
case 'all':
|
|
96
|
+
startDate = '2020-01-01';
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Get usage data
|
|
103
|
+
const dailyUsage = usageDb.getUserUsageByPeriod(uuid, startDate, endDate);
|
|
104
|
+
const totalUsage = usageDb.getUserTotalUsage(uuid);
|
|
105
|
+
const modelDistribution = usageDb.getUserModelDistribution(uuid, startDate, endDate);
|
|
106
|
+
|
|
107
|
+
// Aggregate daily data
|
|
108
|
+
const dailyMap = {};
|
|
109
|
+
for (const record of dailyUsage) {
|
|
110
|
+
if (!dailyMap[record.date]) {
|
|
111
|
+
dailyMap[record.date] = { date: record.date, cost: 0, requests: 0 };
|
|
112
|
+
}
|
|
113
|
+
dailyMap[record.date].cost += record.total_cost_usd;
|
|
114
|
+
dailyMap[record.date].requests += record.request_count;
|
|
115
|
+
}
|
|
116
|
+
const dailyTrend = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
|
|
117
|
+
|
|
118
|
+
res.json({
|
|
119
|
+
user: {
|
|
120
|
+
uuid: user.uuid,
|
|
121
|
+
username: user.username,
|
|
122
|
+
role: user.role
|
|
123
|
+
},
|
|
124
|
+
period,
|
|
125
|
+
totalCost: totalUsage?.total_cost || 0,
|
|
126
|
+
totalRequests: totalUsage?.total_requests || 0,
|
|
127
|
+
totalSessions: totalUsage?.total_sessions || 0,
|
|
128
|
+
dailyTrend,
|
|
129
|
+
modelDistribution
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error('Error fetching user usage:', error);
|
|
133
|
+
res.status(500).json({ error: 'Failed to fetch user usage' });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get global dashboard statistics
|
|
139
|
+
*/
|
|
140
|
+
router.get('/dashboard', (req, res) => {
|
|
141
|
+
try {
|
|
142
|
+
const { period = 'week' } = req.query;
|
|
143
|
+
|
|
144
|
+
// Calculate date range
|
|
145
|
+
const endDate = new Date().toISOString().split('T')[0];
|
|
146
|
+
let startDate;
|
|
147
|
+
|
|
148
|
+
switch (period) {
|
|
149
|
+
case 'week':
|
|
150
|
+
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
151
|
+
break;
|
|
152
|
+
case 'month':
|
|
153
|
+
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
154
|
+
break;
|
|
155
|
+
default:
|
|
156
|
+
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const stats = usageDb.getDashboardStats(startDate, endDate);
|
|
160
|
+
const users = userDb.getAllUsers();
|
|
161
|
+
|
|
162
|
+
// Create username map
|
|
163
|
+
const usernameMap = {};
|
|
164
|
+
for (const user of users) {
|
|
165
|
+
usernameMap[user.uuid] = user.username;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Enrich top users with usernames
|
|
169
|
+
const topUsers = stats.topUsers.map(user => ({
|
|
170
|
+
...user,
|
|
171
|
+
username: usernameMap[user.user_uuid] || 'Unknown'
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
res.json({
|
|
175
|
+
period,
|
|
176
|
+
totals: {
|
|
177
|
+
totalCost: stats.totals?.total_cost || 0,
|
|
178
|
+
totalRequests: stats.totals?.total_requests || 0,
|
|
179
|
+
totalSessions: stats.totals?.total_sessions || 0,
|
|
180
|
+
activeUsers: stats.totals?.active_users || 0,
|
|
181
|
+
totalUsers: users.length
|
|
182
|
+
},
|
|
183
|
+
dailyTrend: stats.dailyTrend,
|
|
184
|
+
modelDistribution: stats.modelDistribution,
|
|
185
|
+
topUsers
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error('Error fetching dashboard stats:', error);
|
|
189
|
+
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Trigger a manual usage scan
|
|
195
|
+
*/
|
|
196
|
+
router.post('/scan', async (req, res) => {
|
|
197
|
+
try {
|
|
198
|
+
const result = await triggerManualScan();
|
|
199
|
+
res.json(result);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error('Error triggering manual scan:', error);
|
|
202
|
+
res.status(500).json({ error: 'Failed to trigger scan' });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
export default router;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Model Pricing Table
|
|
3
|
+
*
|
|
4
|
+
* Prices are in USD per token.
|
|
5
|
+
* Reference: https://www.anthropic.com/pricing
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Pricing per million tokens
|
|
9
|
+
// Reference: https://platform.claude.com/docs/en/about-claude/pricing
|
|
10
|
+
// Cache pricing: read = 0.1x base, write (5min) = 1.25x base, write (1h) = 2x base
|
|
11
|
+
const PRICING_PER_MILLION = {
|
|
12
|
+
// ============ Latest Models ============
|
|
13
|
+
// Claude Opus 4.5 (latest flagship)
|
|
14
|
+
'claude-opus-4-5-20251101': {
|
|
15
|
+
input: 5.00,
|
|
16
|
+
output: 25.00,
|
|
17
|
+
cacheRead: 0.50,
|
|
18
|
+
cacheCreate: 6.25
|
|
19
|
+
},
|
|
20
|
+
// Claude Sonnet 4.5
|
|
21
|
+
'claude-sonnet-4-5-20250929': {
|
|
22
|
+
input: 3.00,
|
|
23
|
+
output: 15.00,
|
|
24
|
+
cacheRead: 0.30,
|
|
25
|
+
cacheCreate: 3.75
|
|
26
|
+
},
|
|
27
|
+
// Claude Haiku 4.5
|
|
28
|
+
'claude-haiku-4-5-20251001': {
|
|
29
|
+
input: 1.00,
|
|
30
|
+
output: 5.00,
|
|
31
|
+
cacheRead: 0.10,
|
|
32
|
+
cacheCreate: 1.25
|
|
33
|
+
},
|
|
34
|
+
// ============ Legacy Models ============
|
|
35
|
+
// Claude Opus 4.1
|
|
36
|
+
'claude-opus-4-1-20250805': {
|
|
37
|
+
input: 15.00,
|
|
38
|
+
output: 75.00,
|
|
39
|
+
cacheRead: 1.50,
|
|
40
|
+
cacheCreate: 18.75
|
|
41
|
+
},
|
|
42
|
+
// Claude Opus 4
|
|
43
|
+
'claude-opus-4-20250514': {
|
|
44
|
+
input: 15.00,
|
|
45
|
+
output: 75.00,
|
|
46
|
+
cacheRead: 1.50,
|
|
47
|
+
cacheCreate: 18.75
|
|
48
|
+
},
|
|
49
|
+
// Claude Sonnet 4
|
|
50
|
+
'claude-sonnet-4-20250514': {
|
|
51
|
+
input: 3.00,
|
|
52
|
+
output: 15.00,
|
|
53
|
+
cacheRead: 0.30,
|
|
54
|
+
cacheCreate: 3.75
|
|
55
|
+
},
|
|
56
|
+
// Claude Sonnet 3.7
|
|
57
|
+
'claude-3-7-sonnet-20250219': {
|
|
58
|
+
input: 3.00,
|
|
59
|
+
output: 15.00,
|
|
60
|
+
cacheRead: 0.30,
|
|
61
|
+
cacheCreate: 3.75
|
|
62
|
+
},
|
|
63
|
+
// Claude Haiku 3.5
|
|
64
|
+
'claude-3-5-haiku-20241022': {
|
|
65
|
+
input: 0.80,
|
|
66
|
+
output: 4.00,
|
|
67
|
+
cacheRead: 0.08,
|
|
68
|
+
cacheCreate: 1.00
|
|
69
|
+
},
|
|
70
|
+
// Claude Haiku 3
|
|
71
|
+
'claude-3-haiku-20240307': {
|
|
72
|
+
input: 0.25,
|
|
73
|
+
output: 1.25,
|
|
74
|
+
cacheRead: 0.03,
|
|
75
|
+
cacheCreate: 0.30
|
|
76
|
+
},
|
|
77
|
+
// ============ Aliases ============
|
|
78
|
+
// Aliases for simplified model names (pointing to latest versions)
|
|
79
|
+
'sonnet': {
|
|
80
|
+
input: 3.00,
|
|
81
|
+
output: 15.00,
|
|
82
|
+
cacheRead: 0.30,
|
|
83
|
+
cacheCreate: 3.75
|
|
84
|
+
},
|
|
85
|
+
'opus': {
|
|
86
|
+
input: 5.00,
|
|
87
|
+
output: 25.00,
|
|
88
|
+
cacheRead: 0.50,
|
|
89
|
+
cacheCreate: 6.25
|
|
90
|
+
},
|
|
91
|
+
'haiku': {
|
|
92
|
+
input: 1.00,
|
|
93
|
+
output: 5.00,
|
|
94
|
+
cacheRead: 0.10,
|
|
95
|
+
cacheCreate: 1.25
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Convert to price per token
|
|
100
|
+
const PRICING = {};
|
|
101
|
+
for (const [model, prices] of Object.entries(PRICING_PER_MILLION)) {
|
|
102
|
+
PRICING[model] = {
|
|
103
|
+
input: prices.input / 1_000_000,
|
|
104
|
+
output: prices.output / 1_000_000,
|
|
105
|
+
cacheRead: prices.cacheRead / 1_000_000,
|
|
106
|
+
cacheCreate: prices.cacheCreate / 1_000_000
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Normalize model name to a standard format
|
|
112
|
+
* @param {string} model - Raw model name
|
|
113
|
+
* @returns {string} Normalized model name
|
|
114
|
+
*/
|
|
115
|
+
function normalizeModelName(model) {
|
|
116
|
+
if (!model) return 'sonnet';
|
|
117
|
+
|
|
118
|
+
const modelLower = model.toLowerCase();
|
|
119
|
+
|
|
120
|
+
// Check for exact match
|
|
121
|
+
if (PRICING[model]) {
|
|
122
|
+
return model;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check for partial matches
|
|
126
|
+
if (modelLower.includes('opus')) {
|
|
127
|
+
return 'opus';
|
|
128
|
+
}
|
|
129
|
+
if (modelLower.includes('haiku')) {
|
|
130
|
+
return 'haiku';
|
|
131
|
+
}
|
|
132
|
+
if (modelLower.includes('sonnet')) {
|
|
133
|
+
return 'sonnet';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Default to sonnet
|
|
137
|
+
return 'sonnet';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Calculate cost for token usage
|
|
142
|
+
* @param {Object} usage - Token usage object
|
|
143
|
+
* @param {string} usage.model - Model name
|
|
144
|
+
* @param {number} usage.inputTokens - Input tokens
|
|
145
|
+
* @param {number} usage.outputTokens - Output tokens
|
|
146
|
+
* @param {number} usage.cacheReadTokens - Cache read tokens
|
|
147
|
+
* @param {number} usage.cacheCreationTokens - Cache creation tokens
|
|
148
|
+
* @returns {number} Cost in USD
|
|
149
|
+
*/
|
|
150
|
+
function calculateCost(usage) {
|
|
151
|
+
const model = normalizeModelName(usage.model);
|
|
152
|
+
const prices = PRICING[model];
|
|
153
|
+
|
|
154
|
+
if (!prices) {
|
|
155
|
+
console.warn(`Unknown model: ${usage.model}, using sonnet pricing`);
|
|
156
|
+
return calculateCost({ ...usage, model: 'sonnet' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const inputCost = (usage.inputTokens || 0) * prices.input;
|
|
160
|
+
const outputCost = (usage.outputTokens || 0) * prices.output;
|
|
161
|
+
const cacheReadCost = (usage.cacheReadTokens || 0) * prices.cacheRead;
|
|
162
|
+
const cacheCreateCost = (usage.cacheCreationTokens || 0) * prices.cacheCreate;
|
|
163
|
+
|
|
164
|
+
return inputCost + outputCost + cacheReadCost + cacheCreateCost;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get pricing for a model
|
|
169
|
+
* @param {string} model - Model name
|
|
170
|
+
* @returns {Object} Pricing object
|
|
171
|
+
*/
|
|
172
|
+
function getModelPricing(model) {
|
|
173
|
+
const normalizedModel = normalizeModelName(model);
|
|
174
|
+
return PRICING[normalizedModel] || PRICING['sonnet'];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Format cost for display
|
|
179
|
+
* @param {number} cost - Cost in USD
|
|
180
|
+
* @returns {string} Formatted cost string
|
|
181
|
+
*/
|
|
182
|
+
function formatCost(cost) {
|
|
183
|
+
if (cost < 0.01) {
|
|
184
|
+
return `$${cost.toFixed(4)}`;
|
|
185
|
+
}
|
|
186
|
+
return `$${cost.toFixed(2)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export {
|
|
190
|
+
PRICING,
|
|
191
|
+
PRICING_PER_MILLION,
|
|
192
|
+
normalizeModelName,
|
|
193
|
+
calculateCost,
|
|
194
|
+
getModelPricing,
|
|
195
|
+
formatCost
|
|
196
|
+
};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Scanner Service
|
|
3
|
+
*
|
|
4
|
+
* Scans Claude session files (JSONL) for token usage data and records it to the database.
|
|
5
|
+
* This handles CLI mode usage tracking and serves as a backup for SDK mode.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Scans all users' Claude session directories
|
|
9
|
+
* - Tracks scan position to avoid re-processing
|
|
10
|
+
* - Updates daily summaries
|
|
11
|
+
* - Cleans up old records (30 days)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { promises as fs } from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { usageDb, userDb } from '../database/db.js';
|
|
17
|
+
import { calculateCost, normalizeModelName } from './pricing.js';
|
|
18
|
+
import { DATA_DIR, getUserPaths } from './user-directories.js';
|
|
19
|
+
|
|
20
|
+
// Scan interval: 5 minutes
|
|
21
|
+
const SCAN_INTERVAL_MS = 5 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
// Retention period: 30 days
|
|
24
|
+
const RETENTION_DAYS = 30;
|
|
25
|
+
|
|
26
|
+
// Scanner state
|
|
27
|
+
let scannerInterval = null;
|
|
28
|
+
let isScanning = false;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Start the usage scanner
|
|
32
|
+
*/
|
|
33
|
+
export function startUsageScanner() {
|
|
34
|
+
console.log('[UsageScanner] Starting usage scanner service');
|
|
35
|
+
|
|
36
|
+
// Run initial scan after a short delay
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
runScan();
|
|
39
|
+
}, 10000);
|
|
40
|
+
|
|
41
|
+
// Schedule periodic scans
|
|
42
|
+
scannerInterval = setInterval(() => {
|
|
43
|
+
runScan();
|
|
44
|
+
}, SCAN_INTERVAL_MS);
|
|
45
|
+
|
|
46
|
+
console.log(`[UsageScanner] Scheduled to run every ${SCAN_INTERVAL_MS / 1000 / 60} minutes`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Stop the usage scanner
|
|
51
|
+
*/
|
|
52
|
+
export function stopUsageScanner() {
|
|
53
|
+
if (scannerInterval) {
|
|
54
|
+
clearInterval(scannerInterval);
|
|
55
|
+
scannerInterval = null;
|
|
56
|
+
console.log('[UsageScanner] Stopped usage scanner service');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run a scan cycle
|
|
62
|
+
*/
|
|
63
|
+
async function runScan() {
|
|
64
|
+
if (isScanning) {
|
|
65
|
+
console.log('[UsageScanner] Scan already in progress, skipping');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
isScanning = true;
|
|
70
|
+
console.log('[UsageScanner] Starting scan cycle');
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Get all users
|
|
74
|
+
const users = userDb.getAllUsers();
|
|
75
|
+
|
|
76
|
+
for (const user of users) {
|
|
77
|
+
if (!user.uuid) continue;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await scanUserSessions(user.uuid);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error(`[UsageScanner] Error scanning user ${user.uuid}:`, error.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Cleanup old records
|
|
87
|
+
const deletedCount = usageDb.cleanupOldRecords(RETENTION_DAYS);
|
|
88
|
+
if (deletedCount > 0) {
|
|
89
|
+
console.log(`[UsageScanner] Cleaned up ${deletedCount} old usage records`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('[UsageScanner] Scan cycle completed');
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('[UsageScanner] Error during scan cycle:', error);
|
|
95
|
+
} finally {
|
|
96
|
+
isScanning = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scan a user's session files
|
|
102
|
+
*/
|
|
103
|
+
async function scanUserSessions(userUuid) {
|
|
104
|
+
const userPaths = getUserPaths(userUuid);
|
|
105
|
+
const projectsDir = path.join(userPaths.claudeDir, 'projects');
|
|
106
|
+
const scanStatePath = path.join(userPaths.claudeDir, '.usage-scan-state.json');
|
|
107
|
+
|
|
108
|
+
// Check if projects directory exists
|
|
109
|
+
try {
|
|
110
|
+
await fs.access(projectsDir);
|
|
111
|
+
} catch {
|
|
112
|
+
// No projects directory yet
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Load scan state
|
|
117
|
+
let scanState = { lastScanTime: null, scannedSessions: {} };
|
|
118
|
+
try {
|
|
119
|
+
const stateContent = await fs.readFile(scanStatePath, 'utf8');
|
|
120
|
+
scanState = JSON.parse(stateContent);
|
|
121
|
+
} catch {
|
|
122
|
+
// File doesn't exist or is invalid, start fresh
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Get all project directories
|
|
126
|
+
const projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
127
|
+
|
|
128
|
+
let newRecordsCount = 0;
|
|
129
|
+
|
|
130
|
+
for (const projectDir of projectDirs) {
|
|
131
|
+
if (!projectDir.isDirectory()) continue;
|
|
132
|
+
|
|
133
|
+
const projectPath = path.join(projectsDir, projectDir.name);
|
|
134
|
+
const sessionFiles = await fs.readdir(projectPath);
|
|
135
|
+
|
|
136
|
+
for (const sessionFile of sessionFiles) {
|
|
137
|
+
if (!sessionFile.endsWith('.jsonl')) continue;
|
|
138
|
+
|
|
139
|
+
const sessionId = sessionFile.replace('.jsonl', '');
|
|
140
|
+
const sessionPath = path.join(projectPath, sessionFile);
|
|
141
|
+
|
|
142
|
+
// Get file stats
|
|
143
|
+
const stats = await fs.stat(sessionPath);
|
|
144
|
+
const lastModified = stats.mtime.toISOString();
|
|
145
|
+
|
|
146
|
+
// Check if we need to scan this session
|
|
147
|
+
const lastScanned = scanState.scannedSessions[sessionId];
|
|
148
|
+
if (lastScanned && lastScanned.lastModified === lastModified) {
|
|
149
|
+
// Already scanned and file hasn't changed
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Scan the session file
|
|
154
|
+
try {
|
|
155
|
+
const recordsAdded = await scanSessionFile(
|
|
156
|
+
userUuid,
|
|
157
|
+
sessionId,
|
|
158
|
+
sessionPath,
|
|
159
|
+
lastScanned?.lastLine || 0
|
|
160
|
+
);
|
|
161
|
+
newRecordsCount += recordsAdded;
|
|
162
|
+
|
|
163
|
+
// Update scan state
|
|
164
|
+
scanState.scannedSessions[sessionId] = {
|
|
165
|
+
lastModified,
|
|
166
|
+
lastLine: lastScanned?.lastLine || 0,
|
|
167
|
+
lastScan: new Date().toISOString()
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error(`[UsageScanner] Error scanning session ${sessionId}:`, error.message);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Save scan state
|
|
176
|
+
scanState.lastScanTime = new Date().toISOString();
|
|
177
|
+
await fs.writeFile(scanStatePath, JSON.stringify(scanState, null, 2));
|
|
178
|
+
|
|
179
|
+
if (newRecordsCount > 0) {
|
|
180
|
+
console.log(`[UsageScanner] User ${userUuid}: added ${newRecordsCount} new records`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Scan a session JSONL file for usage data
|
|
186
|
+
*/
|
|
187
|
+
async function scanSessionFile(userUuid, sessionId, filePath, startLine) {
|
|
188
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
189
|
+
const lines = content.trim().split('\n');
|
|
190
|
+
|
|
191
|
+
let recordsAdded = 0;
|
|
192
|
+
const sessionDates = new Set();
|
|
193
|
+
|
|
194
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
195
|
+
const line = lines[i];
|
|
196
|
+
if (!line.trim()) continue;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const entry = JSON.parse(line);
|
|
200
|
+
|
|
201
|
+
// Only process assistant messages with usage data
|
|
202
|
+
if (entry.type !== 'assistant' || !entry.message?.usage) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const usage = entry.message.usage;
|
|
207
|
+
const model = normalizeModelName(entry.message?.model || 'sonnet');
|
|
208
|
+
|
|
209
|
+
const inputTokens = usage.input_tokens || 0;
|
|
210
|
+
const outputTokens = usage.output_tokens || 0;
|
|
211
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
212
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
213
|
+
|
|
214
|
+
const cost = calculateCost({
|
|
215
|
+
model,
|
|
216
|
+
inputTokens,
|
|
217
|
+
outputTokens,
|
|
218
|
+
cacheReadTokens,
|
|
219
|
+
cacheCreationTokens
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Determine the date from the entry timestamp or use current date
|
|
223
|
+
const entryDate = entry.timestamp
|
|
224
|
+
? new Date(entry.timestamp).toISOString().split('T')[0]
|
|
225
|
+
: new Date().toISOString().split('T')[0];
|
|
226
|
+
|
|
227
|
+
// Insert usage record (source: cli)
|
|
228
|
+
usageDb.insertRecord({
|
|
229
|
+
user_uuid: userUuid,
|
|
230
|
+
session_id: sessionId,
|
|
231
|
+
model,
|
|
232
|
+
input_tokens: inputTokens,
|
|
233
|
+
output_tokens: outputTokens,
|
|
234
|
+
cache_read_tokens: cacheReadTokens,
|
|
235
|
+
cache_creation_tokens: cacheCreationTokens,
|
|
236
|
+
cost_usd: cost,
|
|
237
|
+
source: 'cli',
|
|
238
|
+
created_at: entry.timestamp || new Date().toISOString()
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Update daily summary
|
|
242
|
+
usageDb.upsertDailySummary({
|
|
243
|
+
user_uuid: userUuid,
|
|
244
|
+
date: entryDate,
|
|
245
|
+
model,
|
|
246
|
+
total_input_tokens: inputTokens,
|
|
247
|
+
total_output_tokens: outputTokens,
|
|
248
|
+
total_cost_usd: cost,
|
|
249
|
+
session_count: 0,
|
|
250
|
+
request_count: 1
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
sessionDates.add(entryDate);
|
|
254
|
+
recordsAdded++;
|
|
255
|
+
} catch (parseError) {
|
|
256
|
+
// Skip lines that can't be parsed
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Update session count for each date (once per scan, not per record)
|
|
262
|
+
for (const date of sessionDates) {
|
|
263
|
+
try {
|
|
264
|
+
// We count this as a session activity for today
|
|
265
|
+
// Note: This is approximate since we're scanning multiple messages at once
|
|
266
|
+
} catch (error) {
|
|
267
|
+
// Ignore session count errors
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return recordsAdded;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Manually trigger a scan (for testing or admin use)
|
|
276
|
+
*/
|
|
277
|
+
export async function triggerManualScan() {
|
|
278
|
+
console.log('[UsageScanner] Manual scan triggered');
|
|
279
|
+
await runScan();
|
|
280
|
+
return { success: true, message: 'Scan completed' };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export { SCAN_INTERVAL_MS, RETENTION_DAYS };
|