agentlytics 0.1.15 → 0.1.16

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.
@@ -171,6 +171,73 @@ function safeParse(str) {
171
171
  try { return JSON.parse(str); } catch { return {}; }
172
172
  }
173
173
 
174
+ // ============================================================
175
+ // Usage / quota data from GitHub Copilot internal API
176
+ // ============================================================
177
+
178
+ function getCopilotToken() {
179
+ // GitHub Copilot stores its OAuth token in ~/.config/github-copilot/apps.json
180
+ const appsPath = path.join(os.homedir(), '.config', 'github-copilot', 'apps.json');
181
+ try {
182
+ if (!fs.existsSync(appsPath)) return null;
183
+ const data = JSON.parse(fs.readFileSync(appsPath, 'utf-8'));
184
+ for (const entry of Object.values(data)) {
185
+ if (entry.oauth_token) return { token: entry.oauth_token, user: entry.user || null };
186
+ }
187
+ } catch {}
188
+ return null;
189
+ }
190
+
191
+ function fetchCopilotStatus(token) {
192
+ return new Promise((resolve) => {
193
+ const https = require('https');
194
+ const req = https.get('https://api.github.com/copilot_internal/v2/token', {
195
+ headers: {
196
+ 'Authorization': `token ${token}`,
197
+ 'Accept': 'application/json',
198
+ 'User-Agent': 'agentlytics/1.0',
199
+ },
200
+ timeout: 10000,
201
+ }, (res) => {
202
+ let data = '';
203
+ res.on('data', (chunk) => { data += chunk; });
204
+ res.on('end', () => {
205
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
206
+ });
207
+ });
208
+ req.on('error', () => resolve(null));
209
+ req.on('timeout', () => { req.destroy(); resolve(null); });
210
+ });
211
+ }
212
+
213
+ async function getUsage() {
214
+ const creds = getCopilotToken();
215
+ if (!creds) return null;
216
+
217
+ const status = await fetchCopilotStatus(creds.token);
218
+ if (!status || status.message) return null;
219
+
220
+ return {
221
+ source: 'copilot-cli',
222
+ plan: {
223
+ name: status.sku || null,
224
+ individual: status.individual || false,
225
+ },
226
+ features: {
227
+ chat: status.chat_enabled || false,
228
+ codeReview: status.code_review_enabled || false,
229
+ agentMode: status.agent_mode_auto_approval || false,
230
+ },
231
+ limits: {
232
+ quotas: status.limited_user_quotas || null,
233
+ resetDate: status.limited_user_reset_date || null,
234
+ },
235
+ user: {
236
+ login: creds.user || null,
237
+ },
238
+ };
239
+ }
240
+
174
241
  const labels = { 'copilot-cli': 'Copilot CLI' };
175
242
 
176
- module.exports = { name, labels, getChats, getMessages };
243
+ module.exports = { name, labels, getChats, getMessages, getUsage };
package/editors/cursor.js CHANGED
@@ -341,6 +341,78 @@ function getMessages(chat) {
341
341
  return msgs;
342
342
  }
343
343
 
344
+ // ============================================================
345
+ // Usage / quota data from Cursor REST API
346
+ // ============================================================
347
+
348
+ function getCursorAccessToken() {
349
+ try {
350
+ const db = new Database(GLOBAL_STORAGE_DB, { readonly: true });
351
+ const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'").get();
352
+ db.close();
353
+ return row ? row.value : null;
354
+ } catch { return null; }
355
+ }
356
+
357
+ function cursorApiFetch(endpoint, token) {
358
+ return new Promise((resolve) => {
359
+ const https = require('https');
360
+ const url = `https://api2.cursor.sh/auth/${endpoint}`;
361
+ const req = https.get(url, {
362
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
363
+ timeout: 10000,
364
+ }, (res) => {
365
+ let data = '';
366
+ res.on('data', (chunk) => { data += chunk; });
367
+ res.on('end', () => {
368
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
369
+ });
370
+ });
371
+ req.on('error', () => resolve(null));
372
+ req.on('timeout', () => { req.destroy(); resolve(null); });
373
+ });
374
+ }
375
+
376
+ async function getUsage() {
377
+ const token = getCursorAccessToken();
378
+ if (!token) return null;
379
+
380
+ const [profile, usage] = await Promise.all([
381
+ cursorApiFetch('full_stripe_profile', token),
382
+ cursorApiFetch('usage', token),
383
+ ]);
384
+
385
+ if (!profile && !usage) return null;
386
+
387
+ const result = {
388
+ source: 'cursor',
389
+ plan: {
390
+ name: profile?.individualMembershipType || profile?.membershipType || null,
391
+ status: profile?.subscriptionStatus || null,
392
+ isTeamMember: profile?.isTeamMember || false,
393
+ isYearlyPlan: profile?.isYearlyPlan || false,
394
+ },
395
+ usage: {},
396
+ startOfMonth: usage?.startOfMonth || null,
397
+ };
398
+
399
+ // Parse per-model usage from the usage endpoint
400
+ if (usage) {
401
+ for (const [model, data] of Object.entries(usage)) {
402
+ if (model === 'startOfMonth') continue;
403
+ result.usage[model] = {
404
+ numRequests: data.numRequests || 0,
405
+ numRequestsTotal: data.numRequestsTotal || 0,
406
+ numTokens: data.numTokens || 0,
407
+ maxRequestUsage: data.maxRequestUsage || null,
408
+ maxTokenUsage: data.maxTokenUsage || null,
409
+ };
410
+ }
411
+ }
412
+
413
+ return result;
414
+ }
415
+
344
416
  const labels = { 'cursor': 'Cursor' };
345
417
 
346
- module.exports = { name, labels, getChats, getMessages };
418
+ module.exports = { name, labels, getChats, getMessages, getUsage };
package/editors/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const cursor = require('./cursor');
2
2
  const windsurf = require('./windsurf');
3
+ const antigravity = require('./antigravity');
3
4
  const claude = require('./claude');
4
5
  const vscode = require('./vscode');
5
6
  const zed = require('./zed');
@@ -12,7 +13,7 @@ const commandcode = require('./commandcode');
12
13
  const goose = require('./goose');
13
14
  const kiro = require('./kiro');
14
15
 
15
- const editors = [cursor, windsurf, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode, goose, kiro];
16
+ const editors = [cursor, windsurf, antigravity, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode, goose, kiro];
16
17
 
17
18
  // Build a unified source → display-label map from all editor modules
18
19
  const editorLabels = {};
@@ -60,4 +61,23 @@ function resetCaches() {
60
61
  }
61
62
  }
62
63
 
63
- module.exports = { getAllChats, getMessages, editors, editorLabels, resetCaches };
64
+ /**
65
+ * Get usage / quota data from all editors that support it.
66
+ * Returns an array of usage objects, one per editor/variant.
67
+ */
68
+ async function getAllUsage() {
69
+ const results = [];
70
+ for (const editor of editors) {
71
+ if (typeof editor.getUsage !== 'function') continue;
72
+ try {
73
+ const usage = await editor.getUsage();
74
+ if (!usage) continue;
75
+ // Windsurf returns an array (one per variant), Cursor returns a single object
76
+ if (Array.isArray(usage)) results.push(...usage);
77
+ else results.push(usage);
78
+ } catch { /* skip broken adapters */ }
79
+ }
80
+ return results;
81
+ }
82
+
83
+ module.exports = { getAllChats, getMessages, editors, editorLabels, resetCaches, getAllUsage };
package/editors/vscode.js CHANGED
@@ -315,6 +315,75 @@ function getMessages(chat) {
315
315
  return messages;
316
316
  }
317
317
 
318
+ // ============================================================
319
+ // Usage / quota data from GitHub Copilot internal API
320
+ // ============================================================
321
+
322
+ function getCopilotToken() {
323
+ const appsPath = path.join(os.homedir(), '.config', 'github-copilot', 'apps.json');
324
+ try {
325
+ if (!fs.existsSync(appsPath)) return null;
326
+ const data = JSON.parse(fs.readFileSync(appsPath, 'utf-8'));
327
+ // Pick the first available oauth_token
328
+ for (const entry of Object.values(data)) {
329
+ if (entry.oauth_token) return { token: entry.oauth_token, user: entry.user || null };
330
+ }
331
+ } catch {}
332
+ return null;
333
+ }
334
+
335
+ function fetchCopilotStatus(token) {
336
+ return new Promise((resolve) => {
337
+ const https = require('https');
338
+ const req = https.get('https://api.github.com/copilot_internal/v2/token', {
339
+ headers: {
340
+ 'Authorization': `token ${token}`,
341
+ 'Accept': 'application/json',
342
+ 'User-Agent': 'agentlytics/1.0',
343
+ },
344
+ timeout: 10000,
345
+ }, (res) => {
346
+ let data = '';
347
+ res.on('data', (chunk) => { data += chunk; });
348
+ res.on('end', () => {
349
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
350
+ });
351
+ });
352
+ req.on('error', () => resolve(null));
353
+ req.on('timeout', () => { req.destroy(); resolve(null); });
354
+ });
355
+ }
356
+
357
+ async function getUsage() {
358
+ const creds = getCopilotToken();
359
+ if (!creds) return null;
360
+
361
+ const status = await fetchCopilotStatus(creds.token);
362
+ if (!status || status.message) return null;
363
+
364
+ return {
365
+ source: 'vscode',
366
+ plan: {
367
+ name: status.sku || null,
368
+ individual: status.individual || false,
369
+ },
370
+ features: {
371
+ chat: status.chat_enabled || false,
372
+ codeReview: status.code_review_enabled || false,
373
+ agentMode: status.agent_mode_auto_approval || false,
374
+ xcode: status.xcode || false,
375
+ mcp: status.mcp || false,
376
+ },
377
+ limits: {
378
+ quotas: status.limited_user_quotas || null,
379
+ resetDate: status.limited_user_reset_date || null,
380
+ },
381
+ user: {
382
+ login: creds.user || null,
383
+ },
384
+ };
385
+ }
386
+
318
387
  const labels = { 'vscode': 'VS Code', 'vscode-insiders': 'VS Code Insiders' };
319
388
 
320
- module.exports = { name, labels, getChats, getMessages };
389
+ module.exports = { name, labels, getChats, getMessages, getUsage };
@@ -1,32 +1,14 @@
1
1
  const { execSync } = require('child_process');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const fs = require('fs');
2
5
 
3
- // Windsurf-family variants: Windsurf, Antigravity
6
+ // Windsurf variants: Windsurf, Windsurf Next
4
7
  const VARIANTS = [
5
- { id: 'windsurf', matchKey: 'ide', matchVal: 'windsurf', https: false },
6
- { id: 'windsurf-next', matchKey: 'ide', matchVal: 'windsurf-next', https: false },
7
- { id: 'antigravity', matchKey: 'appDataDir', matchVal: 'antigravity', https: true },
8
+ { id: 'windsurf', matchKey: 'ide', matchVal: 'windsurf', https: false, appName: 'Windsurf', needsMetadata: true },
9
+ { id: 'windsurf-next', matchKey: 'ide', matchVal: 'windsurf-next', https: false, appName: 'Windsurf - Next', needsMetadata: true },
8
10
  ];
9
11
 
10
- // Antigravity model ID to friendly name mapping
11
- const ANTIGRAVITY_MODEL_MAP = {
12
- 'MODEL_PLACEHOLDER_M1': 'claude-3-5-sonnet-20241022',
13
- 'MODEL_PLACEHOLDER_M2': 'claude-3-5-sonnet-20241022',
14
- 'MODEL_PLACEHOLDER_M3': 'claude-3-5-sonnet-20241022',
15
- 'MODEL_PLACEHOLDER_M4': 'claude-3-5-haiku-20241022',
16
- 'MODEL_PLACEHOLDER_M5': 'claude-3-5-haiku-20241022',
17
- 'MODEL_PLACEHOLDER_M6': 'claude-3-5-haiku-20241022',
18
- 'MODEL_PLACEHOLDER_M7': 'claude-3-5-sonnet-20241022',
19
- 'MODEL_PLACEHOLDER_M8': 'claude-3.5-sonnet',
20
- 'MODEL_PLACEHOLDER_M9': 'claude-3.5-sonnet',
21
- 'MODEL_PLACEHOLDER_M10': 'claude-3.5-sonnet',
22
- 'MODEL_CLAUDE_4_5_SONNET': 'claude-4.5-sonnet',
23
- };
24
-
25
- function normalizeAntigravityModel(modelId) {
26
- if (!modelId) return null;
27
- return ANTIGRAVITY_MODEL_MAP[modelId] || modelId;
28
- }
29
-
30
12
  // ============================================================
31
13
  // Cross-platform process utilities
32
14
  // ============================================================
@@ -102,7 +84,7 @@ function getListeningPorts(pid) {
102
84
  }
103
85
 
104
86
  // ============================================================
105
- // Find running Windsurf/Antigravity language server (port + CSRF token)
87
+ // Find running Windsurf language server (port + CSRF token)
106
88
  // ============================================================
107
89
 
108
90
  let _lsCache = null;
@@ -148,7 +130,6 @@ function findLanguageServers() {
148
130
  const ide = ideMatch ? ideMatch[1] : null;
149
131
  const appDataDir = appDirMatch ? appDirMatch[1] : null;
150
132
 
151
- // Antigravity has a separate extension server CSRF token
152
133
  const extCsrfMatch = commandLine.match(/--extension_server_csrf_token\s+(\S+)/);
153
134
 
154
135
  // Check for explicit server port (Antigravity uses --server_port)
@@ -169,9 +150,8 @@ function findLanguageServers() {
169
150
  port = Math.min(...ports);
170
151
  }
171
152
 
172
- if (ide || appDataDir) {
173
- const isHttps = appDataDir?.includes('antigravity');
174
- _lsCache.push({ ide, appDataDir, port, csrf, pid, extCsrf: extCsrfMatch ? extCsrfMatch[1] : null, isHttps });
153
+ if (ide) {
154
+ _lsCache.push({ ide, appDataDir, port, csrf, pid, extCsrf: extCsrfMatch ? extCsrfMatch[1] : null, isHttps: false });
175
155
  }
176
156
  }
177
157
 
@@ -193,18 +173,15 @@ function getLsForVariant(variant) {
193
173
  // Connect protocol HTTP client for language server RPC
194
174
  // ============================================================
195
175
 
196
- function callRpc(port, csrf, method, body, isHttps = false, extCsrf = null, useMainCsrf = false) {
176
+ function callRpc(port, csrf, method, body, extCsrf = null) {
197
177
  const data = JSON.stringify(body || {});
198
- const scheme = isHttps ? 'https' : 'http';
199
- const url = `${scheme}://127.0.0.1:${port}/exa.language_server_pb.LanguageServerService/${method}`;
200
- const insecure = isHttps ? '-k ' : '';
178
+ const url = `http://127.0.0.1:${port}/exa.language_server_pb.LanguageServerService/${method}`;
201
179
 
202
- // For Antigravity, use main CSRF. For Windsurf, use extension CSRF if available.
203
- const actualCsrf = useMainCsrf ? csrf : (extCsrf || csrf);
180
+ const actualCsrf = extCsrf || csrf;
204
181
 
205
182
  try {
206
183
  const result = execSync(
207
- `curl -s ${insecure}-X POST ${JSON.stringify(url)} ` +
184
+ `curl -s -X POST ${JSON.stringify(url)} ` +
208
185
  `-H "Content-Type: application/json" ` +
209
186
  `-H "x-codeium-csrf-token: ${actualCsrf}" ` +
210
187
  `-d ${JSON.stringify(data)} ` +
@@ -220,7 +197,7 @@ function callRpc(port, csrf, method, body, isHttps = false, extCsrf = null, useM
220
197
  // ============================================================
221
198
 
222
199
  const name = 'windsurf';
223
- const sources = ['windsurf', 'windsurf-next', 'antigravity'];
200
+ const sources = ['windsurf', 'windsurf-next'];
224
201
 
225
202
  function getChats() {
226
203
  const chats = [];
@@ -229,17 +206,13 @@ function getChats() {
229
206
  const ls = getLsForVariant(variant);
230
207
  if (!ls) continue;
231
208
 
232
- // Antigravity uses main CSRF, Windsurf uses extension CSRF
233
- const useMainCsrf = variant.id === 'antigravity';
234
- const resp = callRpc(ls.port, ls.csrf, 'GetAllCascadeTrajectories', {}, ls.isHttps, ls.extCsrf, useMainCsrf);
209
+ const resp = callRpc(ls.port, ls.csrf, 'GetAllCascadeTrajectories', {}, ls.extCsrf);
235
210
  if (!resp || !resp.trajectorySummaries) continue;
236
211
 
237
212
  for (const [cascadeId, summary] of Object.entries(resp.trajectorySummaries)) {
238
213
  const ws = (summary.workspaces || [])[0];
239
214
  const folder = ws?.workspaceFolderAbsoluteUri?.replace('file://', '') || null;
240
215
  const rawModel = summary.lastGeneratorModelUid;
241
- // Normalize Antigravity models so they show correctly in dashboard
242
- const normalizedModel = variant.id === 'antigravity' && rawModel ? normalizeAntigravityModel(rawModel) : rawModel;
243
216
  chats.push({
244
217
  source: variant.id,
245
218
  composerId: cascadeId,
@@ -253,9 +226,8 @@ function getChats() {
253
226
  _port: ls.port,
254
227
  _csrf: ls.csrf,
255
228
  _extCsrf: ls.extCsrf,
256
- _isHttps: ls.isHttps,
257
229
  _stepCount: summary.stepCount,
258
- _model: normalizedModel,
230
+ _model: rawModel,
259
231
  _rawModel: rawModel,
260
232
  });
261
233
  }
@@ -267,19 +239,16 @@ function getChats() {
267
239
  function getSteps(chat) {
268
240
  if (!chat._port || !chat._csrf) return [];
269
241
 
270
- // Determine if this is Antigravity based on source
271
- const isAntigravity = chat.source === 'antigravity';
272
-
273
242
  // Prefer GetCascadeTrajectorySteps (returns more steps than GetCascadeTrajectory)
274
243
  const resp = callRpc(chat._port, chat._csrf, 'GetCascadeTrajectorySteps', {
275
244
  cascadeId: chat.composerId,
276
- }, chat._isHttps, chat._extCsrf, isAntigravity);
245
+ }, chat._extCsrf);
277
246
  if (resp && resp.steps && resp.steps.length > 0) return resp.steps;
278
247
 
279
248
  // Fallback to old method
280
249
  const resp2 = callRpc(chat._port, chat._csrf, 'GetCascadeTrajectory', {
281
250
  cascadeId: chat.composerId,
282
- }, chat._isHttps, chat._extCsrf, isAntigravity);
251
+ }, chat._extCsrf);
283
252
  if (resp2 && resp2.trajectory && resp2.trajectory.steps) return resp2.trajectory.steps;
284
253
 
285
254
  return [];
@@ -291,10 +260,9 @@ function getSteps(chat) {
291
260
  * We find the overlap with step-based messages by matching the last user message content.
292
261
  */
293
262
  function getTailMessages(chat, stepMessages) {
294
- const isAntigravity = chat.source === 'antigravity';
295
263
  const resp = callRpc(chat._port, chat._csrf, 'GetCascadeTrajectory', {
296
264
  cascadeId: chat.composerId,
297
- }, chat._isHttps, chat._extCsrf, isAntigravity);
265
+ }, chat._extCsrf);
298
266
  if (!resp || !resp.trajectory) return [];
299
267
 
300
268
  const gm = resp.trajectory.generatorMetadata || [];
@@ -350,7 +318,7 @@ function getTailMessages(chat, stepMessages) {
350
318
  return tail;
351
319
  }
352
320
 
353
- function parseStep(step, isAntigravity = false) {
321
+ function parseStep(step) {
354
322
  const type = step.type || '';
355
323
  const meta = step.metadata || {};
356
324
 
@@ -386,12 +354,11 @@ function parseStep(step, isAntigravity = false) {
386
354
  }
387
355
  }
388
356
  if (parts.length > 0) {
389
- // Try both generatorModel (Antigravity) and generatorModelUid (Windsurf)
390
- const model = meta.generatorModel || meta.generatorModelUid;
357
+ const model = meta.generatorModelUid;
391
358
  return {
392
359
  role: 'assistant',
393
360
  content: parts.join('\n'),
394
- _model: isAntigravity && model ? normalizeAntigravityModel(model) : model,
361
+ _model: model,
395
362
  _toolCalls,
396
363
  };
397
364
  }
@@ -462,10 +429,9 @@ function parseStep(step, isAntigravity = false) {
462
429
 
463
430
  function getMessages(chat) {
464
431
  const steps = getSteps(chat);
465
- const isAntigravity = chat.source === 'antigravity';
466
432
  const messages = [];
467
433
  for (const step of steps) {
468
- const msg = parseStep(step, isAntigravity);
434
+ const msg = parseStep(step);
469
435
  if (msg) messages.push(msg);
470
436
  }
471
437
 
@@ -478,8 +444,137 @@ function getMessages(chat) {
478
444
  return messages;
479
445
  }
480
446
 
447
+ // ============================================================
448
+ // Usage / quota data from language server RPC
449
+ // ============================================================
450
+
451
+ function getWindsurfApiKey(appName) {
452
+ if (!appName) return null;
453
+ try {
454
+ const HOME = os.homedir();
455
+ let dbPath;
456
+ switch (process.platform) {
457
+ case 'darwin':
458
+ dbPath = path.join(HOME, 'Library', 'Application Support', appName, 'User', 'globalStorage', 'state.vscdb');
459
+ break;
460
+ case 'win32':
461
+ dbPath = path.join(HOME, 'AppData', 'Roaming', appName, 'User', 'globalStorage', 'state.vscdb');
462
+ break;
463
+ default:
464
+ dbPath = path.join(HOME, '.config', appName, 'User', 'globalStorage', 'state.vscdb');
465
+ }
466
+ if (!fs.existsSync(dbPath)) return null;
467
+ const Database = require('better-sqlite3');
468
+ const db = new Database(dbPath, { readonly: true });
469
+ const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus'").get();
470
+ db.close();
471
+ if (!row) return null;
472
+ const parsed = JSON.parse(row.value);
473
+ return parsed.apiKey || null;
474
+ } catch { return null; }
475
+ }
476
+
477
+ function getUsage() {
478
+ const results = [];
479
+
480
+ for (const variant of VARIANTS) {
481
+ const ls = getLsForVariant(variant);
482
+ if (!ls) continue;
483
+
484
+ const apiKey = getWindsurfApiKey(variant.appName);
485
+ if (!apiKey) continue;
486
+ const body = {
487
+ metadata: {
488
+ api_key: apiKey,
489
+ ide_name: variant.id,
490
+ ide_version: '1.0.0',
491
+ extension_version: '1.0.0',
492
+ locale: 'en',
493
+ },
494
+ };
495
+
496
+ const resp = callRpc(ls.port, ls.csrf, 'GetUserStatus', body, ls.extCsrf);
497
+ if (!resp || !resp.userStatus) continue;
498
+
499
+ const us = resp.userStatus;
500
+ const ps = us.planStatus || {};
501
+ const pi = ps.planInfo || {};
502
+ const modelConfigs = (us.cascadeModelConfigData || {}).clientModelConfigs || [];
503
+
504
+ const models = modelConfigs.map((m) => {
505
+ const qi = m.quotaInfo || {};
506
+ return {
507
+ label: m.label || null,
508
+ model: m.modelOrAlias?.model || null,
509
+ remainingFraction: qi.remainingFraction != null ? qi.remainingFraction : null,
510
+ resetTime: qi.resetTime || null,
511
+ supportsImages: m.supportsImages || false,
512
+ };
513
+ });
514
+
515
+ // Raw values are in internal units (÷100 for display credits)
516
+ const promptAlloc = (ps.availablePromptCredits || 0) / 100;
517
+ const promptUsed = (ps.usedPromptCredits || 0) / 100;
518
+ const flexAlloc = (ps.availableFlexCredits || 0) / 100;
519
+ const flexUsed = (ps.usedFlexCredits || 0) / 100;
520
+ const flowAlloc = (ps.availableFlowCredits || 0) / 100;
521
+ const monthlyDisplay = (pi.monthlyPromptCredits || 0) / 100;
522
+
523
+ const remainingPrompt = Math.max(0, promptAlloc - promptUsed);
524
+ const remainingFlex = Math.max(0, flexAlloc - flexUsed);
525
+ const totalRemaining = remainingPrompt + remainingFlex;
526
+
527
+ // Credit multipliers per model
528
+ const creditMultipliers = (pi.creditMultiplierOverrides || []).reduce((acc, entry) => {
529
+ const model = entry.modelOrAlias?.model;
530
+ if (model && entry.creditMultiplier != null) acc[model] = entry.creditMultiplier;
531
+ return acc;
532
+ }, {});
533
+
534
+ results.push({
535
+ source: variant.id,
536
+ plan: {
537
+ name: pi.planName || null,
538
+ tier: pi.teamsTier || null,
539
+ monthlyPromptCredits: monthlyDisplay,
540
+ monthlyFlowCredits: (pi.monthlyFlowCredits || 0) / 100,
541
+ canBuyMoreCredits: pi.canBuyMoreCredits || false,
542
+ },
543
+ usage: {
544
+ promptCredits: { allocated: promptAlloc, used: promptUsed, remaining: remainingPrompt },
545
+ flexCredits: { allocated: flexAlloc, used: flexUsed, remaining: remainingFlex },
546
+ flowCredits: { allocated: flowAlloc },
547
+ totalRemainingCredits: totalRemaining,
548
+ },
549
+ billingCycle: {
550
+ start: ps.planStart || null,
551
+ end: ps.planEnd || null,
552
+ },
553
+ topUp: ps.topUpStatus ? {
554
+ monthlyAmount: ps.topUpStatus.monthlyTopUpAmount || null,
555
+ increment: ps.topUpStatus.topUpIncrement || null,
556
+ } : null,
557
+ features: {
558
+ webSearch: pi.cascadeWebSearchEnabled || false,
559
+ browser: pi.browserEnabled || false,
560
+ knowledgeBase: pi.knowledgeBaseEnabled || false,
561
+ autoRunCommands: pi.cascadeCanAutoRunCommands || false,
562
+ commitMessages: pi.canGenerateCommitMessages || false,
563
+ },
564
+ models,
565
+ creditMultipliers,
566
+ user: {
567
+ name: us.name || null,
568
+ email: us.email || null,
569
+ },
570
+ });
571
+ }
572
+
573
+ return results.length > 0 ? results : null;
574
+ }
575
+
481
576
  function resetCache() { _lsCache = null; }
482
577
 
483
- const labels = { 'windsurf': 'Windsurf', 'windsurf-next': 'Windsurf Next', 'antigravity': 'Antigravity' };
578
+ const labels = { 'windsurf': 'Windsurf', 'windsurf-next': 'Windsurf Next' };
484
579
 
485
- module.exports = { name, sources, labels, getChats, getMessages, resetCache };
580
+ module.exports = { name, sources, labels, getChats, getMessages, resetCache, getUsage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -287,6 +287,16 @@ app.get('/api/share-image', (req, res) => {
287
287
  }
288
288
  });
289
289
 
290
+ app.get('/api/usage', async (req, res) => {
291
+ try {
292
+ const { getAllUsage } = require('./editors');
293
+ const usage = await getAllUsage();
294
+ res.json(usage);
295
+ } catch (err) {
296
+ res.status(500).json({ error: err.message });
297
+ }
298
+ });
299
+
290
300
  app.get('/api/refetch', async (req, res) => {
291
301
  res.writeHead(200, {
292
302
  'Content-Type': 'text/event-stream',
package/ui/src/App.jsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { Routes, Route, NavLink } from 'react-router-dom'
3
- import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon } from 'lucide-react'
3
+ import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon } from 'lucide-react'
4
4
  import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
6
  import AnimatedLogo from './components/AnimatedLogo'
@@ -14,6 +14,7 @@ import ProjectDetail from './pages/ProjectDetail'
14
14
  import CostAnalysis from './pages/CostAnalysis'
15
15
  import SqlViewer from './pages/SqlViewer'
16
16
  import Settings from './pages/Settings'
17
+ import Subscriptions from './pages/Subscriptions'
17
18
  import RelayDashboard from './pages/RelayDashboard'
18
19
  import RelayUserDetail from './pages/RelayUserDetail'
19
20
 
@@ -90,6 +91,7 @@ export default function App() {
90
91
  { to: '/costs', icon: DollarSign, label: 'Costs' },
91
92
  { to: '/analysis', icon: BarChart3, label: 'Analysis' },
92
93
  { to: '/compare', icon: GitCompare, label: 'Compare' },
94
+ { to: '/subscriptions', icon: CreditCard, label: 'Subscriptions' },
93
95
  { to: '/sql', icon: Database, label: 'SQL' },
94
96
  ]
95
97
 
@@ -213,6 +215,7 @@ export default function App() {
213
215
  <Route path="/costs" element={<CostAnalysis overview={overview} />} />
214
216
  <Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
215
217
  <Route path="/compare" element={<Compare overview={overview} />} />
218
+ <Route path="/subscriptions" element={<Subscriptions />} />
216
219
  <Route path="/sql" element={<SqlViewer />} />
217
220
  <Route path="/settings" element={<Settings />} />
218
221
  </Routes>
package/ui/src/lib/api.js CHANGED
@@ -203,6 +203,11 @@ export async function fetchToolCalls(name, opts = {}) {
203
203
  return res.json();
204
204
  }
205
205
 
206
+ export async function fetchUsage() {
207
+ const res = await fetch(`${BASE}/api/usage`);
208
+ return res.json();
209
+ }
210
+
206
211
  // ── Relay API ──
207
212
 
208
213
  export async function fetchMode() {