@ian2018cs/agenthub 0.1.19 → 0.1.21
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/assets/index-CPhbi9pq.js +151 -0
- package/dist/assets/index-CrVue26F.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/index.js +246 -1
- package/server/services/gemini-usage-scanner.js +249 -0
- package/server/services/pricing.js +46 -0
- package/server/services/usage-scanner.js +7 -0
- package/server/services/user-directories.js +101 -0
- package/dist/assets/index-CjfXmHwn.css +0 -32
- package/dist/assets/index-DH4WdlQa.js +0 -151
package/server/index.js
CHANGED
|
@@ -56,7 +56,7 @@ import skillsRoutes from './routes/skills.js';
|
|
|
56
56
|
import settingsRoutes from './routes/settings.js';
|
|
57
57
|
import { initializeDatabase, userDb } from './database/db.js';
|
|
58
58
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
59
|
-
import { getUserPaths, initCodexDirectories } from './services/user-directories.js';
|
|
59
|
+
import { getUserPaths, initCodexDirectories, initGeminiDirectories } from './services/user-directories.js';
|
|
60
60
|
import { startUsageScanner } from './services/usage-scanner.js';
|
|
61
61
|
|
|
62
62
|
// File system watcher for projects folder - per user
|
|
@@ -184,6 +184,7 @@ const server = http.createServer(app);
|
|
|
184
184
|
|
|
185
185
|
const ptySessionsMap = new Map();
|
|
186
186
|
const codexPtySessionsMap = new Map();
|
|
187
|
+
const geminiPtySessionsMap = new Map();
|
|
187
188
|
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
188
189
|
|
|
189
190
|
// Single WebSocket server that handles both paths
|
|
@@ -602,6 +603,8 @@ wss.on('connection', (ws, request) => {
|
|
|
602
603
|
handleShellConnection(ws, userData);
|
|
603
604
|
} else if (pathname === '/codex') {
|
|
604
605
|
handleCodexConnection(ws, userData);
|
|
606
|
+
} else if (pathname === '/gemini') {
|
|
607
|
+
handleGeminiConnection(ws, userData);
|
|
605
608
|
} else if (pathname === '/ws') {
|
|
606
609
|
handleChatConnection(ws, userData);
|
|
607
610
|
} else {
|
|
@@ -1278,6 +1281,232 @@ function handleCodexConnection(ws, userData) {
|
|
|
1278
1281
|
console.error('[ERROR] Codex WebSocket error:', error);
|
|
1279
1282
|
});
|
|
1280
1283
|
}
|
|
1284
|
+
|
|
1285
|
+
function handleGeminiConnection(ws, userData) {
|
|
1286
|
+
console.log('💎 Gemini client connected');
|
|
1287
|
+
const userUuid = userData?.uuid || null;
|
|
1288
|
+
let geminiProcess = null;
|
|
1289
|
+
let ptySessionKey = null;
|
|
1290
|
+
|
|
1291
|
+
ws.on('message', async (message) => {
|
|
1292
|
+
try {
|
|
1293
|
+
const data = JSON.parse(message);
|
|
1294
|
+
console.log('📨 Gemini message received:', data.type);
|
|
1295
|
+
|
|
1296
|
+
if (data.type === 'init') {
|
|
1297
|
+
const projectPath = data.projectPath || process.cwd();
|
|
1298
|
+
|
|
1299
|
+
ptySessionKey = `gemini_${projectPath}_${userUuid || 'default'}`;
|
|
1300
|
+
|
|
1301
|
+
const existingSession = geminiPtySessionsMap.get(ptySessionKey);
|
|
1302
|
+
if (existingSession) {
|
|
1303
|
+
console.log('♻️ Reconnecting to existing Gemini PTY session:', ptySessionKey);
|
|
1304
|
+
geminiProcess = existingSession.pty;
|
|
1305
|
+
clearTimeout(existingSession.timeoutId);
|
|
1306
|
+
|
|
1307
|
+
ws.send(JSON.stringify({
|
|
1308
|
+
type: 'output',
|
|
1309
|
+
data: `\x1b[36m[Reconnected to existing Gemini session]\x1b[0m\r\n`
|
|
1310
|
+
}));
|
|
1311
|
+
|
|
1312
|
+
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
|
1313
|
+
console.log(`📜 Sending ${existingSession.buffer.length} buffered Gemini messages`);
|
|
1314
|
+
existingSession.buffer.forEach(bufferedData => {
|
|
1315
|
+
ws.send(JSON.stringify({ type: 'output', data: bufferedData }));
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
existingSession.ws = ws;
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
console.log('[INFO] Starting Gemini in:', projectPath);
|
|
1324
|
+
|
|
1325
|
+
ws.send(JSON.stringify({
|
|
1326
|
+
type: 'output',
|
|
1327
|
+
data: `\x1b[36mStarting Gemini in: ${projectPath}\x1b[0m\r\n`
|
|
1328
|
+
}));
|
|
1329
|
+
|
|
1330
|
+
try {
|
|
1331
|
+
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
1332
|
+
const shellCommand = os.platform() === 'win32'
|
|
1333
|
+
? `Set-Location -Path "${projectPath}"; gemini`
|
|
1334
|
+
: `cd "${projectPath}" && gemini`;
|
|
1335
|
+
const shellArgs = os.platform() === 'win32'
|
|
1336
|
+
? ['-Command', shellCommand]
|
|
1337
|
+
: ['-c', shellCommand];
|
|
1338
|
+
|
|
1339
|
+
const termCols = data.cols || 80;
|
|
1340
|
+
const termRows = data.rows || 24;
|
|
1341
|
+
console.log('📐 Gemini terminal dimensions:', termCols, 'x', termRows);
|
|
1342
|
+
|
|
1343
|
+
// Build environment with per-user isolated GEMINI_CLI_HOME for config isolation
|
|
1344
|
+
const geminiEnv = {
|
|
1345
|
+
...process.env,
|
|
1346
|
+
TERM: 'xterm-256color',
|
|
1347
|
+
COLORTERM: 'truecolor',
|
|
1348
|
+
FORCE_COLOR: '3',
|
|
1349
|
+
BROWSER: 'echo "OPEN_URL:"'
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
if (userUuid) {
|
|
1353
|
+
const userPaths = getUserPaths(userUuid);
|
|
1354
|
+
// Set GEMINI_CLI_HOME to per-user directory for complete isolation
|
|
1355
|
+
geminiEnv.GEMINI_CLI_HOME = userPaths.geminiHomeDir;
|
|
1356
|
+
console.log('🔐 Set GEMINI_CLI_HOME for Gemini user isolation:', userPaths.geminiHomeDir);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// Inject API credentials from server environment variables
|
|
1360
|
+
if (process.env.GEMINI_API_KEY) {
|
|
1361
|
+
geminiEnv.GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
|
1362
|
+
}
|
|
1363
|
+
if (process.env.GOOGLE_GEMINI_BASE_URL) {
|
|
1364
|
+
geminiEnv.GOOGLE_GEMINI_BASE_URL = process.env.GOOGLE_GEMINI_BASE_URL;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// Sync skills before starting gemini (mirrors .claude/skills/ into .gemini/skills/)
|
|
1368
|
+
if (userUuid) {
|
|
1369
|
+
try {
|
|
1370
|
+
await initGeminiDirectories(userUuid);
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
console.error('[WARN] Failed to sync gemini skills:', err.message);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
geminiProcess = pty.spawn(shell, shellArgs, {
|
|
1377
|
+
name: 'xterm-256color',
|
|
1378
|
+
cols: termCols,
|
|
1379
|
+
rows: termRows,
|
|
1380
|
+
cwd: os.homedir(),
|
|
1381
|
+
env: geminiEnv
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
console.log('🟢 Gemini process started with PTY, PID:', geminiProcess.pid);
|
|
1385
|
+
|
|
1386
|
+
geminiPtySessionsMap.set(ptySessionKey, {
|
|
1387
|
+
pty: geminiProcess,
|
|
1388
|
+
ws: ws,
|
|
1389
|
+
buffer: [],
|
|
1390
|
+
timeoutId: null,
|
|
1391
|
+
projectPath
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
geminiProcess.onData((rawData) => {
|
|
1395
|
+
const session = geminiPtySessionsMap.get(ptySessionKey);
|
|
1396
|
+
if (!session) return;
|
|
1397
|
+
|
|
1398
|
+
if (session.buffer.length < 5000) {
|
|
1399
|
+
session.buffer.push(rawData);
|
|
1400
|
+
} else {
|
|
1401
|
+
session.buffer.shift();
|
|
1402
|
+
session.buffer.push(rawData);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
1406
|
+
let outputData = rawData;
|
|
1407
|
+
|
|
1408
|
+
// URL detection
|
|
1409
|
+
const patterns = [
|
|
1410
|
+
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
1411
|
+
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
1412
|
+
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
1413
|
+
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
1414
|
+
];
|
|
1415
|
+
|
|
1416
|
+
patterns.forEach(pattern => {
|
|
1417
|
+
let match;
|
|
1418
|
+
while ((match = pattern.exec(rawData)) !== null) {
|
|
1419
|
+
const url = match[1];
|
|
1420
|
+
session.ws.send(JSON.stringify({ type: 'url_open', url }));
|
|
1421
|
+
if (pattern.source.includes('OPEN_URL')) {
|
|
1422
|
+
outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
session.ws.send(JSON.stringify({ type: 'output', data: outputData }));
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
geminiProcess.onExit((exitCode) => {
|
|
1432
|
+
console.log('🔚 Gemini process exited with code:', exitCode.exitCode);
|
|
1433
|
+
const session = geminiPtySessionsMap.get(ptySessionKey);
|
|
1434
|
+
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
1435
|
+
session.ws.send(JSON.stringify({
|
|
1436
|
+
type: 'output',
|
|
1437
|
+
data: `\r\n\x1b[33mGemini exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
|
1438
|
+
}));
|
|
1439
|
+
}
|
|
1440
|
+
if (session && session.timeoutId) {
|
|
1441
|
+
clearTimeout(session.timeoutId);
|
|
1442
|
+
}
|
|
1443
|
+
geminiPtySessionsMap.delete(ptySessionKey);
|
|
1444
|
+
geminiProcess = null;
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
} catch (spawnError) {
|
|
1448
|
+
console.error('[ERROR] Error spawning Gemini process:', spawnError);
|
|
1449
|
+
ws.send(JSON.stringify({
|
|
1450
|
+
type: 'output',
|
|
1451
|
+
data: `\r\n\x1b[31mError starting Gemini: ${spawnError.message}\x1b[0m\r\n`
|
|
1452
|
+
}));
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
} else if (data.type === 'input') {
|
|
1456
|
+
if (geminiProcess && geminiProcess.write) {
|
|
1457
|
+
try {
|
|
1458
|
+
geminiProcess.write(data.data);
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
console.error('Error writing to Gemini process:', error);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
} else if (data.type === 'resize') {
|
|
1464
|
+
if (geminiProcess && geminiProcess.resize) {
|
|
1465
|
+
geminiProcess.resize(data.cols, data.rows);
|
|
1466
|
+
}
|
|
1467
|
+
} else if (data.type === 'terminate') {
|
|
1468
|
+
console.log('🛑 Gemini terminate request for session:', ptySessionKey);
|
|
1469
|
+
const session = geminiPtySessionsMap.get(ptySessionKey);
|
|
1470
|
+
if (session) {
|
|
1471
|
+
if (session.timeoutId) clearTimeout(session.timeoutId);
|
|
1472
|
+
if (session.pty && session.pty.kill) session.pty.kill();
|
|
1473
|
+
geminiPtySessionsMap.delete(ptySessionKey);
|
|
1474
|
+
}
|
|
1475
|
+
geminiProcess = null;
|
|
1476
|
+
}
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
console.error('[ERROR] Gemini WebSocket error:', error.message);
|
|
1479
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1480
|
+
ws.send(JSON.stringify({
|
|
1481
|
+
type: 'output',
|
|
1482
|
+
data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
|
|
1483
|
+
}));
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
ws.on('close', () => {
|
|
1489
|
+
console.log('🔌 Gemini client disconnected');
|
|
1490
|
+
if (ptySessionKey) {
|
|
1491
|
+
const session = geminiPtySessionsMap.get(ptySessionKey);
|
|
1492
|
+
if (session) {
|
|
1493
|
+
console.log('⏳ Gemini PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
|
|
1494
|
+
session.ws = null;
|
|
1495
|
+
session.timeoutId = setTimeout(() => {
|
|
1496
|
+
console.log('⏰ Gemini PTY session timeout, killing process:', ptySessionKey);
|
|
1497
|
+
if (session.pty && session.pty.kill) {
|
|
1498
|
+
session.pty.kill();
|
|
1499
|
+
}
|
|
1500
|
+
geminiPtySessionsMap.delete(ptySessionKey);
|
|
1501
|
+
}, PTY_SESSION_TIMEOUT);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
ws.on('error', (error) => {
|
|
1507
|
+
console.error('[ERROR] Gemini WebSocket error:', error);
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1281
1510
|
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
|
1282
1511
|
try {
|
|
1283
1512
|
const multer = (await import('multer')).default;
|
|
@@ -1944,6 +2173,22 @@ async function startServer() {
|
|
|
1944
2173
|
console.error('[WARN] Codex migration error:', err.message);
|
|
1945
2174
|
}
|
|
1946
2175
|
|
|
2176
|
+
// Migrate existing users: ensure gemini directories exist
|
|
2177
|
+
try {
|
|
2178
|
+
const allUsers = userDb.getAllUsers();
|
|
2179
|
+
for (const user of allUsers) {
|
|
2180
|
+
if (!user.uuid) continue;
|
|
2181
|
+
try {
|
|
2182
|
+
await initGeminiDirectories(user.uuid);
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
console.error(`[WARN] Failed to init gemini dirs for user ${user.uuid}:`, err.message);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
console.log(`[INFO] Gemini directory migration complete for ${allUsers.length} user(s)`);
|
|
2188
|
+
} catch (err) {
|
|
2189
|
+
console.error('[WARN] Gemini migration error:', err.message);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1947
2192
|
// Projects watcher is now per-user, initialized when user connects via WebSocket
|
|
1948
2193
|
});
|
|
1949
2194
|
} catch (error) {
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Usage Scanner Service
|
|
3
|
+
*
|
|
4
|
+
* Scans Gemini CLI session files (JSON) for token usage data and records it to the database.
|
|
5
|
+
* Gemini sessions are stored in:
|
|
6
|
+
* {geminiHomeDir}/.gemini/tmp/{projectHash}/chats/session-*.json
|
|
7
|
+
*
|
|
8
|
+
* Key differences from Claude/Codex sessions:
|
|
9
|
+
* - Gemini uses plain JSON files (not JSONL)
|
|
10
|
+
* - Each file is a full session object with a "messages" array
|
|
11
|
+
* - Token data is in messages with type === "gemini": { input, output, cached, thoughts, tool, total }
|
|
12
|
+
* - "thoughts" tokens are bundled with output tokens (same billing rate)
|
|
13
|
+
* - Pro models have two pricing tiers: ≤200k total tokens and >200k total tokens
|
|
14
|
+
* - The scanner selects the appropriate tier based on tokens.total per message
|
|
15
|
+
* - Deduplication is done via sessionId + message.id
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { promises as fs } from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { usageDb, userDb } from '../database/db.js';
|
|
21
|
+
import { calculateCost, normalizeModelName } from './pricing.js';
|
|
22
|
+
import { getUserPaths } from './user-directories.js';
|
|
23
|
+
|
|
24
|
+
const GEMINI_PRO_LARGE_THRESHOLD = 200000; // tokens above this use the higher pricing tier
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scan all Gemini session files for a user
|
|
28
|
+
* @param {string} userUuid - User UUID
|
|
29
|
+
*/
|
|
30
|
+
export async function scanUserGeminiSessions(userUuid) {
|
|
31
|
+
const userPaths = getUserPaths(userUuid);
|
|
32
|
+
const tmpDir = path.join(userPaths.geminiHomeDir, '.gemini', 'tmp');
|
|
33
|
+
const scanStatePath = path.join(userPaths.geminiHomeDir, '.gemini-usage-scan-state.json');
|
|
34
|
+
|
|
35
|
+
// Check if gemini tmp directory exists
|
|
36
|
+
try {
|
|
37
|
+
await fs.access(tmpDir);
|
|
38
|
+
} catch {
|
|
39
|
+
// No gemini sessions directory yet
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Load scan state
|
|
44
|
+
// scannedMessages: { [sessionId]: string[] } (array of processed message IDs)
|
|
45
|
+
let scanState = { lastScanTime: null, scannedMessages: {} };
|
|
46
|
+
try {
|
|
47
|
+
const stateContent = await fs.readFile(scanStatePath, 'utf8');
|
|
48
|
+
scanState = JSON.parse(stateContent);
|
|
49
|
+
if (!scanState.scannedMessages) scanState.scannedMessages = {};
|
|
50
|
+
} catch {
|
|
51
|
+
// File doesn't exist or is invalid, start fresh
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let newRecordsCount = 0;
|
|
55
|
+
|
|
56
|
+
// Sessions are organized as tmp/{projectHash}/chats/session-*.json
|
|
57
|
+
let projectDirs;
|
|
58
|
+
try {
|
|
59
|
+
projectDirs = await fs.readdir(tmpDir, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const projectDir of projectDirs) {
|
|
65
|
+
if (!projectDir.isDirectory()) continue;
|
|
66
|
+
const chatsDir = path.join(tmpDir, projectDir.name, 'chats');
|
|
67
|
+
|
|
68
|
+
let chatFiles;
|
|
69
|
+
try {
|
|
70
|
+
chatFiles = await fs.readdir(chatsDir);
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const chatFile of chatFiles) {
|
|
76
|
+
if (!chatFile.startsWith('session-') || !chatFile.endsWith('.json')) continue;
|
|
77
|
+
|
|
78
|
+
const sessionPath = path.join(chatsDir, chatFile);
|
|
79
|
+
|
|
80
|
+
// Get file stats to check if modified
|
|
81
|
+
let stats;
|
|
82
|
+
try {
|
|
83
|
+
stats = await fs.stat(sessionPath);
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const lastModified = stats.mtime.toISOString();
|
|
88
|
+
|
|
89
|
+
// Use file path as session key for state tracking (since sessionId is inside the file)
|
|
90
|
+
const stateKey = `${projectDir.name}/${chatFile}`;
|
|
91
|
+
const lastScanned = scanState.scannedMessages[stateKey];
|
|
92
|
+
|
|
93
|
+
// If file hasn't changed and we have previously scanned it, skip
|
|
94
|
+
if (lastScanned && lastScanned.lastModified === lastModified) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Scan the session file
|
|
99
|
+
try {
|
|
100
|
+
const processedIds = new Set(lastScanned?.processedIds || []);
|
|
101
|
+
const scanResult = await scanGeminiSessionFile(
|
|
102
|
+
userUuid,
|
|
103
|
+
sessionPath,
|
|
104
|
+
processedIds
|
|
105
|
+
);
|
|
106
|
+
newRecordsCount += scanResult.recordsAdded;
|
|
107
|
+
|
|
108
|
+
// Update scan state
|
|
109
|
+
scanState.scannedMessages[stateKey] = {
|
|
110
|
+
lastModified,
|
|
111
|
+
processedIds: Array.from(scanResult.processedIds),
|
|
112
|
+
lastScan: new Date().toISOString()
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`[GeminiUsageScanner] Error scanning session ${chatFile}:`, error.message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Save scan state
|
|
121
|
+
scanState.lastScanTime = new Date().toISOString();
|
|
122
|
+
try {
|
|
123
|
+
await fs.writeFile(scanStatePath, JSON.stringify(scanState, null, 2));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error(`[GeminiUsageScanner] Error saving scan state for user ${userUuid}:`, error.message);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (newRecordsCount > 0) {
|
|
129
|
+
console.log(`[GeminiUsageScanner] User ${userUuid}: added ${newRecordsCount} new Gemini usage records`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Scan a single Gemini session JSON file for usage data
|
|
135
|
+
* @param {string} userUuid - User UUID
|
|
136
|
+
* @param {string} filePath - Full path to session JSON file
|
|
137
|
+
* @param {Set<string>} processedIds - Already processed message IDs (for deduplication)
|
|
138
|
+
* @returns {{ recordsAdded: number, processedIds: Set<string> }}
|
|
139
|
+
*/
|
|
140
|
+
async function scanGeminiSessionFile(userUuid, filePath, processedIds) {
|
|
141
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
142
|
+
let session;
|
|
143
|
+
try {
|
|
144
|
+
session = JSON.parse(content);
|
|
145
|
+
} catch {
|
|
146
|
+
return { recordsAdded: 0, processedIds };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sessionId = session.sessionId || path.basename(filePath, '.json');
|
|
150
|
+
const messages = session.messages || [];
|
|
151
|
+
|
|
152
|
+
let recordsAdded = 0;
|
|
153
|
+
|
|
154
|
+
for (const message of messages) {
|
|
155
|
+
// Only process gemini-type messages that have token data
|
|
156
|
+
if (message.type !== 'gemini' || !message.tokens) continue;
|
|
157
|
+
|
|
158
|
+
const messageId = message.id;
|
|
159
|
+
if (!messageId) continue;
|
|
160
|
+
|
|
161
|
+
// Skip already processed messages
|
|
162
|
+
if (processedIds.has(messageId)) continue;
|
|
163
|
+
|
|
164
|
+
const tokens = message.tokens;
|
|
165
|
+
const inputTokens = tokens.input || 0;
|
|
166
|
+
const cachedTokens = tokens.cached || 0;
|
|
167
|
+
// thoughts tokens are billed at the same rate as output tokens
|
|
168
|
+
const outputTokens = (tokens.output || 0) + (tokens.thoughts || 0);
|
|
169
|
+
const totalTokens = tokens.total || 0;
|
|
170
|
+
|
|
171
|
+
// Skip if no meaningful usage
|
|
172
|
+
if (inputTokens === 0 && outputTokens === 0) {
|
|
173
|
+
processedIds.add(messageId);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rawModel = message.model || 'gemini-3-pro-preview';
|
|
178
|
+
let model = normalizeModelName(rawModel);
|
|
179
|
+
|
|
180
|
+
// For pro models, apply the higher pricing tier if total tokens exceed threshold
|
|
181
|
+
// The -large suffix keys are defined in pricing.js exclusively for this purpose
|
|
182
|
+
if (
|
|
183
|
+
totalTokens > GEMINI_PRO_LARGE_THRESHOLD &&
|
|
184
|
+
(model === 'gemini-3.1-pro-preview' || model === 'gemini-3-pro-preview')
|
|
185
|
+
) {
|
|
186
|
+
model = `${model}-large`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const cost = calculateCost({
|
|
190
|
+
model,
|
|
191
|
+
inputTokens,
|
|
192
|
+
outputTokens,
|
|
193
|
+
cacheReadTokens: cachedTokens,
|
|
194
|
+
cacheCreationTokens: 0
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const entryTimestamp = message.timestamp || session.startTime || new Date().toISOString();
|
|
198
|
+
const entryDate = entryTimestamp.split('T')[0];
|
|
199
|
+
|
|
200
|
+
// Insert usage record with source='gemini'
|
|
201
|
+
usageDb.insertRecord({
|
|
202
|
+
user_uuid: userUuid,
|
|
203
|
+
session_id: sessionId,
|
|
204
|
+
model,
|
|
205
|
+
raw_model: rawModel,
|
|
206
|
+
input_tokens: inputTokens,
|
|
207
|
+
output_tokens: outputTokens,
|
|
208
|
+
cache_read_tokens: cachedTokens,
|
|
209
|
+
cache_creation_tokens: 0,
|
|
210
|
+
cost_usd: cost,
|
|
211
|
+
source: 'gemini',
|
|
212
|
+
created_at: entryTimestamp
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Update daily summary
|
|
216
|
+
usageDb.upsertDailySummary({
|
|
217
|
+
user_uuid: userUuid,
|
|
218
|
+
date: entryDate,
|
|
219
|
+
model,
|
|
220
|
+
total_input_tokens: inputTokens,
|
|
221
|
+
total_output_tokens: outputTokens,
|
|
222
|
+
total_cost_usd: cost,
|
|
223
|
+
session_count: 0,
|
|
224
|
+
request_count: 1
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
processedIds.add(messageId);
|
|
228
|
+
recordsAdded++;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { recordsAdded, processedIds };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Manually trigger a Gemini scan (for testing or admin use)
|
|
236
|
+
*/
|
|
237
|
+
export async function triggerGeminiScan() {
|
|
238
|
+
console.log('[GeminiUsageScanner] Manual Gemini scan triggered');
|
|
239
|
+
const users = userDb.getAllUsers();
|
|
240
|
+
for (const user of users) {
|
|
241
|
+
if (!user.uuid) continue;
|
|
242
|
+
try {
|
|
243
|
+
await scanUserGeminiSessions(user.uuid);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error(`[GeminiUsageScanner] Error scanning user ${user.uuid}:`, error.message);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { success: true, message: 'Gemini scan completed' };
|
|
249
|
+
}
|
|
@@ -212,6 +212,45 @@ const PRICING_PER_MILLION = {
|
|
|
212
212
|
cacheCreate5m: 0,
|
|
213
213
|
cacheCreate1h: 0
|
|
214
214
|
},
|
|
215
|
+
// ============ Gemini Models (Google Gemini CLI) ============
|
|
216
|
+
// Pro 系列 ≤200k tokens 档
|
|
217
|
+
'gemini-3.1-pro-preview': {
|
|
218
|
+
input: 2.00,
|
|
219
|
+
output: 12.00,
|
|
220
|
+
cacheRead: 0.20,
|
|
221
|
+
cacheCreate5m: 0,
|
|
222
|
+
cacheCreate1h: 0
|
|
223
|
+
},
|
|
224
|
+
'gemini-3-pro-preview': {
|
|
225
|
+
input: 2.00,
|
|
226
|
+
output: 12.00,
|
|
227
|
+
cacheRead: 0.20,
|
|
228
|
+
cacheCreate5m: 0,
|
|
229
|
+
cacheCreate1h: 0
|
|
230
|
+
},
|
|
231
|
+
// Pro 系列 >200k tokens 档(高价档,仅由 gemini-usage-scanner.js 内部使用)
|
|
232
|
+
'gemini-3.1-pro-preview-large': {
|
|
233
|
+
input: 4.00,
|
|
234
|
+
output: 18.00,
|
|
235
|
+
cacheRead: 0.40,
|
|
236
|
+
cacheCreate5m: 0,
|
|
237
|
+
cacheCreate1h: 0
|
|
238
|
+
},
|
|
239
|
+
'gemini-3-pro-preview-large': {
|
|
240
|
+
input: 4.00,
|
|
241
|
+
output: 18.00,
|
|
242
|
+
cacheRead: 0.40,
|
|
243
|
+
cacheCreate5m: 0,
|
|
244
|
+
cacheCreate1h: 0
|
|
245
|
+
},
|
|
246
|
+
// Flash 系列(单一价格档)
|
|
247
|
+
'gemini-3-flash-preview': {
|
|
248
|
+
input: 0.50,
|
|
249
|
+
output: 3.00,
|
|
250
|
+
cacheRead: 0.05,
|
|
251
|
+
cacheCreate5m: 0,
|
|
252
|
+
cacheCreate1h: 0
|
|
253
|
+
},
|
|
215
254
|
// ============ Aliases ============
|
|
216
255
|
// Aliases for simplified model names (pointing to latest versions)
|
|
217
256
|
'sonnet': {
|
|
@@ -284,6 +323,13 @@ function normalizeModelName(model) {
|
|
|
284
323
|
return 'gpt-5-codex'; // gpt-5-codex 或未知 codex 变体
|
|
285
324
|
}
|
|
286
325
|
|
|
326
|
+
// Check for Gemini model patterns (Google Gemini CLI models)
|
|
327
|
+
if (modelLower.includes('gemini')) {
|
|
328
|
+
if (modelLower.includes('flash')) return 'gemini-3-flash-preview';
|
|
329
|
+
if (modelLower.includes('3.1') || modelLower.includes('3-1')) return 'gemini-3.1-pro-preview';
|
|
330
|
+
return 'gemini-3-pro-preview';
|
|
331
|
+
}
|
|
332
|
+
|
|
287
333
|
// Check for GPT-5 model patterns (OpenAI)
|
|
288
334
|
if (modelLower.includes('gpt-5') || modelLower.startsWith('gpt5')) {
|
|
289
335
|
if (modelLower.includes('nano')) return 'gpt-5-nano';
|
|
@@ -17,6 +17,7 @@ import { usageDb, userDb } from '../database/db.js';
|
|
|
17
17
|
import { calculateCost, normalizeModelName } from './pricing.js';
|
|
18
18
|
import { DATA_DIR, getUserPaths } from './user-directories.js';
|
|
19
19
|
import { scanUserCodexSessions } from './codex-usage-scanner.js';
|
|
20
|
+
import { scanUserGeminiSessions } from './gemini-usage-scanner.js';
|
|
20
21
|
|
|
21
22
|
// Scan interval: 5 minutes
|
|
22
23
|
const SCAN_INTERVAL_MS = 5 * 60 * 1000;
|
|
@@ -88,6 +89,12 @@ async function runScan() {
|
|
|
88
89
|
} catch (error) {
|
|
89
90
|
console.error(`[UsageScanner] Error scanning Codex sessions for user ${user.uuid}:`, error.message);
|
|
90
91
|
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await scanUserGeminiSessions(user.uuid);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error(`[UsageScanner] Error scanning Gemini sessions for user ${user.uuid}:`, error.message);
|
|
97
|
+
}
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
// Cleanup old records
|