@ian2018cs/agenthub 0.1.20 → 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/index.html CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-C6s2SqL8.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-CPhbi9pq.js"></script>
29
29
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BeVl62c0.js">
30
30
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-C_VWDoZS.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-utils-00TdZexr.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian2018cs/agenthub",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "A web-based UI for AI Agents",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
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