@tasszz2k/agentlens 0.5.10 → 0.5.12

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/cost.js CHANGED
@@ -5,6 +5,14 @@ import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { loadConfig } from './config.js';
7
7
  const execFileAsync = promisify(execFile);
8
+ function extractErrorMessage(err) {
9
+ if (!(err instanceof Error))
10
+ return String(err);
11
+ const cause = err.cause;
12
+ if (cause instanceof Error)
13
+ return `${err.message}: ${cause.message}`;
14
+ return err.message;
15
+ }
8
16
  function localDateStr(d) {
9
17
  const y = d.getFullYear();
10
18
  const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -161,7 +169,7 @@ export async function fetchClaudeCodeCosts() {
161
169
  totalOutputTokens: 0,
162
170
  models: [],
163
171
  period,
164
- error: err instanceof Error ? err.message : String(err),
172
+ error: extractErrorMessage(err),
165
173
  };
166
174
  }
167
175
  }
@@ -183,6 +191,26 @@ const CURSOR_TIER_NAMES = {
183
191
  function normalizeCursorTierName(key) {
184
192
  return CURSOR_TIER_NAMES[key] ?? key;
185
193
  }
194
+ function parseUsageBucket(bucket) {
195
+ if (!bucket || typeof bucket !== 'object')
196
+ return undefined;
197
+ const b = bucket;
198
+ if (typeof b.enabled !== 'boolean')
199
+ return undefined;
200
+ const used = typeof b.used === 'number' ? b.used : Number(b.used);
201
+ const limit = typeof b.limit === 'number' ? b.limit : Number(b.limit);
202
+ return {
203
+ enabled: b.enabled,
204
+ usedCents: Number.isFinite(used) ? used : 0,
205
+ limitCents: Number.isFinite(limit) ? limit : 0,
206
+ };
207
+ }
208
+ function parseIsoMs(value) {
209
+ if (typeof value !== 'string')
210
+ return undefined;
211
+ const ms = Date.parse(value);
212
+ return Number.isFinite(ms) ? ms : undefined;
213
+ }
186
214
  async function fetchCursorUsageSummary(token) {
187
215
  try {
188
216
  const res = await fetch('https://cursor.com/api/usage-summary', {
@@ -195,27 +223,32 @@ async function fetchCursorUsageSummary(token) {
195
223
  const mt = data.membershipType;
196
224
  if (typeof mt === 'string' && mt !== 'free')
197
225
  planType = mt;
198
- let onDemand;
199
- const ind = data.individualUsage;
200
- const indOd = ind?.onDemand;
201
- if (indOd && typeof indOd.enabled === 'boolean') {
202
- onDemand = {
203
- enabled: indOd.enabled,
204
- usedCents: typeof indOd.used === 'number' ? indOd.used : 0,
205
- limitCents: typeof indOd.limit === 'number' ? indOd.limit : 0,
206
- };
226
+ let limitType;
227
+ if (data.limitType === 'team' || data.limitType === 'individual') {
228
+ limitType = data.limitType;
207
229
  }
208
- let teamOnDemand;
230
+ const ind = data.individualUsage;
209
231
  const team = data.teamUsage;
210
- const teamOd = team?.onDemand;
211
- if (teamOd && typeof teamOd.enabled === 'boolean') {
212
- teamOnDemand = {
213
- enabled: teamOd.enabled,
214
- usedCents: typeof teamOd.used === 'number' ? teamOd.used : 0,
215
- limitCents: typeof teamOd.limit === 'number' ? teamOd.limit : 0,
216
- };
217
- }
218
- return { planType, onDemand, teamOnDemand };
232
+ // New shape: individualUsage.overall is the user's monthly bucket.
233
+ // Legacy shape: individualUsage.onDemand was the on-demand tracker.
234
+ const included = parseUsageBucket(ind?.overall) ??
235
+ parseUsageBucket(ind?.included);
236
+ // On-demand now lives under teamUsage.onDemand for team plans; fall back
237
+ // to legacy individualUsage.onDemand if present.
238
+ const onDemand = parseUsageBucket(team?.onDemand) ??
239
+ parseUsageBucket(ind?.onDemand);
240
+ const teamOnDemand = parseUsageBucket(team?.onDemand);
241
+ const pooled = parseUsageBucket(team?.pooled);
242
+ return {
243
+ planType,
244
+ limitType,
245
+ included,
246
+ onDemand,
247
+ teamOnDemand,
248
+ pooled,
249
+ billingCycleStartMs: parseIsoMs(data.billingCycleStart),
250
+ billingCycleEndMs: parseIsoMs(data.billingCycleEnd),
251
+ };
219
252
  }
220
253
  catch {
221
254
  return null;
@@ -350,7 +383,26 @@ async function getCursorEmail() {
350
383
  }
351
384
  return readVscdbKey('cursorAuth/cachedEmail');
352
385
  }
353
- async function fetchCursorTeamId(token) {
386
+ async function fetchCursorMe(token) {
387
+ try {
388
+ const res = await fetch('https://cursor.com/api/auth/me', {
389
+ headers: { Cookie: `WorkosCursorSessionToken=${token}` },
390
+ });
391
+ if (!res.ok)
392
+ return null;
393
+ const data = (await res.json());
394
+ if (typeof data.id !== 'number')
395
+ return null;
396
+ return {
397
+ userId: data.id,
398
+ email: typeof data.email === 'string' ? data.email : '',
399
+ };
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
405
+ async function fetchCursorTeamInfo(token) {
354
406
  try {
355
407
  const res = await fetch('https://cursor.com/api/dashboard/teams', {
356
408
  method: 'POST',
@@ -365,7 +417,58 @@ async function fetchCursorTeamId(token) {
365
417
  return null;
366
418
  const data = (await res.json());
367
419
  const first = data.teams?.[0];
368
- return typeof first?.id === 'number' ? first.id : null;
420
+ if (!first || typeof first.id !== 'number')
421
+ return null;
422
+ const startMs = typeof first.billingCycleStart === 'string' ? Number(first.billingCycleStart) : NaN;
423
+ const endMs = typeof first.billingCycleEnd === 'string' ? Number(first.billingCycleEnd) : NaN;
424
+ return {
425
+ teamId: first.id,
426
+ billingCycleStartMs: Number.isFinite(startMs) ? startMs : 0,
427
+ billingCycleEndMs: Number.isFinite(endMs) ? endMs : 0,
428
+ };
429
+ }
430
+ catch {
431
+ return null;
432
+ }
433
+ }
434
+ async function fetchCursorDailySpend(token, teamId, userId, periodStartMs, periodEndMs) {
435
+ try {
436
+ const res = await fetch('https://cursor.com/api/dashboard/get-daily-spend-by-category', {
437
+ method: 'POST',
438
+ headers: {
439
+ Cookie: buildCursorCookie(token, { team_id: teamId }),
440
+ 'Content-Type': 'application/json',
441
+ Origin: 'https://cursor.com',
442
+ },
443
+ body: JSON.stringify({ teamId, userId, periodStartMs, periodEndMs, groupBy: 1, spendType: 1 }),
444
+ });
445
+ if (!res.ok)
446
+ return null;
447
+ const data = (await res.json());
448
+ if (!Array.isArray(data.dailySpend))
449
+ return null;
450
+ const byCategory = {};
451
+ for (const entry of data.dailySpend) {
452
+ const cat = typeof entry.category === 'string' ? entry.category : '';
453
+ if (!cat)
454
+ continue;
455
+ const tokens = typeof entry.totalTokens === 'string' ? Number(entry.totalTokens) : 0;
456
+ const spend = typeof entry.spendCents === 'number' ? entry.spendCents : 0;
457
+ if (!byCategory[cat])
458
+ byCategory[cat] = { tokens: 0, spendCents: 0 };
459
+ byCategory[cat].tokens += tokens;
460
+ byCategory[cat].spendCents += spend;
461
+ }
462
+ const models = Object.entries(byCategory).map(([cat, agg]) => ({
463
+ model: cat,
464
+ inputTokens: agg.tokens,
465
+ outputTokens: 0,
466
+ cacheWriteTokens: 0,
467
+ cacheReadTokens: 0,
468
+ costUsd: agg.spendCents / 100,
469
+ }));
470
+ models.sort((a, b) => b.inputTokens - a.inputTokens);
471
+ return models;
369
472
  }
370
473
  catch {
371
474
  return null;
@@ -387,72 +490,90 @@ export async function fetchCursorCosts() {
387
490
  + ' To get your token: cursor.com > DevTools (F12) > Application > Cookies > WorkosCursorSessionToken',
388
491
  };
389
492
  }
390
- const usageRes = await fetch('https://cursor.com/api/usage', {
391
- headers: { Cookie: `WorkosCursorSessionToken=${token}` },
392
- });
393
- if (!usageRes.ok) {
394
- const body = await usageRes.text().catch(() => '');
395
- const parsed = (() => { try {
396
- return JSON.parse(body);
397
- }
398
- catch {
399
- return null;
400
- } })();
401
- const detail = parsed?.error ?? `HTTP ${usageRes.status}`;
402
- const hint = detail.includes('origin') || detail.includes('authenticated')
403
- ? '\n Try updating your token: agentlens config --set-cursor-token <token>\n'
404
- + ' Get it from: cursor.com > DevTools (F12) > Application > Cookies > WorkosCursorSessionToken'
405
- : '';
406
- return {
407
- tool: 'Cursor',
408
- totalCostUsd: 0,
409
- totalInputTokens: 0,
410
- totalOutputTokens: 0,
411
- models: [],
412
- period,
413
- error: `API error: ${detail}${hint}`,
414
- };
415
- }
416
- const usageData = (await usageRes.json());
417
- const models = [];
418
- let totalTokens = 0;
493
+ const [usageRes, meResult, configResult, usageSummaryResult] = await Promise.all([
494
+ fetch('https://cursor.com/api/usage', {
495
+ headers: { Cookie: `WorkosCursorSessionToken=${token}` },
496
+ }).catch((err) => err),
497
+ fetchCursorMe(token),
498
+ loadConfig().catch(() => ({ cursorTeamId: undefined })),
499
+ fetchCursorUsageSummary(token),
500
+ ]);
501
+ const subErrors = [];
502
+ // Premium request counts from /api/usage (legacy; may be empty for new dollar-based plans)
419
503
  let totalRequests = 0;
420
504
  let maxRequests;
421
- for (const [key, val] of Object.entries(usageData)) {
422
- if (key === 'startOfMonth' || typeof val !== 'object' || !val)
423
- continue;
424
- const entry = val;
425
- const numTokens = typeof entry.numTokens === 'number' ? entry.numTokens : 0;
426
- const reqs = typeof entry.numRequests === 'number' ? entry.numRequests : 0;
427
- const maxReq = typeof entry.maxRequestUsage === 'number' ? entry.maxRequestUsage : undefined;
428
- totalTokens += numTokens;
429
- totalRequests += reqs;
430
- if (maxReq != null)
431
- maxRequests = (maxRequests ?? 0) + maxReq;
432
- models.push({
433
- model: normalizeCursorTierName(key),
434
- inputTokens: numTokens,
435
- outputTokens: 0,
436
- cacheWriteTokens: 0,
437
- cacheReadTokens: 0,
438
- costUsd: 0,
439
- numRequests: reqs,
440
- });
505
+ if (usageRes instanceof Error) {
506
+ subErrors.push(`usage: ${usageRes.message}`);
507
+ }
508
+ else if (usageRes.ok) {
509
+ try {
510
+ const usageData = (await usageRes.json());
511
+ for (const [key, val] of Object.entries(usageData)) {
512
+ if (key === 'startOfMonth' || typeof val !== 'object' || !val)
513
+ continue;
514
+ const entry = val;
515
+ const reqs = typeof entry.numRequests === 'number' ? entry.numRequests : 0;
516
+ const maxReq = typeof entry.maxRequestUsage === 'number' ? entry.maxRequestUsage : undefined;
517
+ totalRequests += reqs;
518
+ if (maxReq != null)
519
+ maxRequests = (maxRequests ?? 0) + maxReq;
520
+ }
521
+ }
522
+ catch (err) {
523
+ subErrors.push(`usage parse: ${extractErrorMessage(err)}`);
524
+ }
525
+ }
526
+ else {
527
+ subErrors.push(`usage: HTTP ${usageRes.status}`);
441
528
  }
442
- models.sort((a, b) => b.inputTokens - a.inputTokens);
443
529
  let planType;
530
+ let limitType;
531
+ let included;
444
532
  let onDemand;
445
533
  let teamOnDemand;
534
+ let pooled;
535
+ let billingCycleStartMs;
536
+ let billingCycleEndMs;
446
537
  let leaderboard;
447
- const [emailResult, configResult, usageSummaryResult] = await Promise.all([
448
- getCursorEmail(),
449
- loadConfig().catch(() => ({ cursorTeamId: undefined })),
450
- fetchCursorUsageSummary(token),
451
- ]);
452
- const email = emailResult;
538
+ if (usageSummaryResult) {
539
+ planType = usageSummaryResult.planType;
540
+ limitType = usageSummaryResult.limitType;
541
+ included = usageSummaryResult.included;
542
+ onDemand = usageSummaryResult.onDemand;
543
+ teamOnDemand = usageSummaryResult.teamOnDemand;
544
+ pooled = usageSummaryResult.pooled;
545
+ billingCycleStartMs = usageSummaryResult.billingCycleStartMs;
546
+ billingCycleEndMs = usageSummaryResult.billingCycleEndMs;
547
+ }
548
+ else {
549
+ subErrors.push('usage-summary: failed');
550
+ }
551
+ const email = meResult?.email || await getCursorEmail();
453
552
  let teamId = configResult.cursorTeamId ?? null;
454
- if (teamId == null && email) {
455
- teamId = await fetchCursorTeamId(token);
553
+ let billingStart = billingCycleStartMs ?? 0;
554
+ let billingEnd = billingCycleEndMs ?? 0;
555
+ const teamInfo = await fetchCursorTeamInfo(token);
556
+ if (teamInfo) {
557
+ teamId = teamInfo.teamId;
558
+ // Prefer team endpoint cycle if usage-summary didn't provide one.
559
+ if (billingStart === 0)
560
+ billingStart = teamInfo.billingCycleStartMs;
561
+ if (billingEnd === 0)
562
+ billingEnd = teamInfo.billingCycleEndMs;
563
+ }
564
+ // Per-model token + cost data from daily-spend API
565
+ let models = [];
566
+ let totalTokens = 0;
567
+ const userId = meResult?.userId ?? null;
568
+ if (teamId != null && userId != null && billingStart > 0 && billingEnd > 0) {
569
+ const dailyModels = await fetchCursorDailySpend(token, teamId, userId, billingStart, billingEnd);
570
+ if (dailyModels && dailyModels.length > 0) {
571
+ models = dailyModels;
572
+ totalTokens = models.reduce((sum, m) => sum + m.inputTokens, 0);
573
+ }
574
+ else {
575
+ subErrors.push('daily-spend: no data');
576
+ }
456
577
  }
457
578
  let leaderboardResult;
458
579
  if (teamId != null && email) {
@@ -463,15 +584,23 @@ export async function fetchCursorCosts() {
463
584
  else {
464
585
  leaderboardResult = { status: 'fulfilled', value: null };
465
586
  }
466
- if (usageSummaryResult) {
467
- planType = usageSummaryResult.planType;
468
- onDemand = usageSummaryResult.onDemand;
469
- teamOnDemand = usageSummaryResult.teamOnDemand;
470
- }
471
587
  if (leaderboardResult.status === 'fulfilled' && leaderboardResult.value) {
472
588
  leaderboard = leaderboardResult.value;
473
589
  }
474
- const cursorCostUsd = onDemand?.enabled ? onDemand.usedCents / 100 : 0;
590
+ const includedSpendCents = included?.enabled ? included.usedCents : 0;
591
+ const onDemandSpendCents = onDemand?.enabled ? onDemand.usedCents : 0;
592
+ const dailySpendCents = models.reduce((sum, m) => sum + Math.round(m.costUsd * 100), 0);
593
+ // Prefer the daily-spend total when present (matches dashboard exactly);
594
+ // fall back to included + on-demand from the usage-summary buckets.
595
+ const totalCents = dailySpendCents > 0 ? dailySpendCents : includedSpendCents + onDemandSpendCents;
596
+ const cursorCostUsd = totalCents / 100;
597
+ // Only surface sub-errors when we have nothing useful to show.
598
+ const hasAnyData = cursorCostUsd > 0 ||
599
+ included != null ||
600
+ onDemand != null ||
601
+ pooled != null ||
602
+ models.length > 0 ||
603
+ totalRequests > 0;
475
604
  return {
476
605
  tool: 'Cursor',
477
606
  totalCostUsd: cursorCostUsd,
@@ -480,11 +609,17 @@ export async function fetchCursorCosts() {
480
609
  totalRequests,
481
610
  maxRequests,
482
611
  planType,
612
+ limitType,
613
+ included,
483
614
  onDemand,
484
615
  teamOnDemand,
616
+ pooled,
617
+ billingCycleStartMs: billingStart > 0 ? billingStart : undefined,
618
+ billingCycleEndMs: billingEnd > 0 ? billingEnd : undefined,
485
619
  leaderboard,
486
620
  models,
487
621
  period,
622
+ error: hasAnyData ? undefined : (subErrors.length > 0 ? subErrors.join('; ') : 'No data returned from Cursor APIs'),
488
623
  };
489
624
  }
490
625
  catch (err) {
@@ -495,7 +630,7 @@ export async function fetchCursorCosts() {
495
630
  totalOutputTokens: 0,
496
631
  models: [],
497
632
  period,
498
- error: err instanceof Error ? err.message : String(err),
633
+ error: extractErrorMessage(err),
499
634
  };
500
635
  }
501
636
  }
@@ -509,6 +644,24 @@ async function getClaudeSessionToken() {
509
644
  }
510
645
  }
511
646
  const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
647
+ function normalizeClaudePlanType(raw) {
648
+ const lower = raw.toLowerCase();
649
+ if (lower.includes('enterprise'))
650
+ return 'enterprise';
651
+ if (lower.includes('team'))
652
+ return 'team';
653
+ if (lower === 'max' || lower.includes('max'))
654
+ return 'max';
655
+ if (lower === 'pro' || lower.includes('professional') || lower.includes('pro_'))
656
+ return 'pro';
657
+ if (lower === 'free' || lower.includes('free'))
658
+ return 'free';
659
+ if (lower.includes('subscription') || lower.includes('stripe'))
660
+ return undefined;
661
+ if (lower.includes('individual'))
662
+ return 'pro';
663
+ return undefined;
664
+ }
512
665
  async function fetchClaudeBootstrap(token) {
513
666
  try {
514
667
  const res = await fetch('https://claude.ai/api/bootstrap', {
@@ -526,9 +679,48 @@ async function fetchClaudeBootstrap(token) {
526
679
  for (const m of memberships) {
527
680
  const org = m.organization;
528
681
  if (org && typeof org.uuid === 'string') {
682
+ let planType;
683
+ for (const key of ['billing_type', 'plan_type', 'subscription_type', 'plan', 'type']) {
684
+ const val = org[key];
685
+ if (typeof val === 'string' && val) {
686
+ const normalized = normalizeClaudePlanType(val);
687
+ if (normalized) {
688
+ planType = normalized;
689
+ break;
690
+ }
691
+ }
692
+ }
693
+ if (!planType) {
694
+ const settings = org.settings;
695
+ if (settings) {
696
+ for (const key of ['billing_type', 'plan_type']) {
697
+ const val = settings[key];
698
+ if (typeof val === 'string' && val) {
699
+ const normalized = normalizeClaudePlanType(val);
700
+ if (normalized) {
701
+ planType = normalized;
702
+ break;
703
+ }
704
+ }
705
+ }
706
+ }
707
+ }
708
+ const activeFlags = org.active_flags;
709
+ if (!planType && Array.isArray(activeFlags)) {
710
+ for (const flag of activeFlags) {
711
+ if (typeof flag !== 'string')
712
+ continue;
713
+ const normalized = normalizeClaudePlanType(flag);
714
+ if (normalized) {
715
+ planType = normalized;
716
+ break;
717
+ }
718
+ }
719
+ }
529
720
  orgs.push({
530
721
  uuid: org.uuid,
531
722
  name: typeof org.name === 'string' ? org.name : '',
723
+ planType,
532
724
  });
533
725
  }
534
726
  }
@@ -539,6 +731,78 @@ async function fetchClaudeBootstrap(token) {
539
731
  return null;
540
732
  }
541
733
  }
734
+ async function fetchClaudeAdminUsage(adminKey, startDate, endDate) {
735
+ try {
736
+ const params = new URLSearchParams({
737
+ group_by: 'model',
738
+ start_date: startDate,
739
+ end_date: endDate,
740
+ interval: 'month',
741
+ });
742
+ const res = await fetch(`https://api.anthropic.com/v1/organizations/usage_report/messages?${params.toString()}`, {
743
+ headers: {
744
+ 'x-api-key': adminKey,
745
+ 'anthropic-version': '2023-06-01',
746
+ },
747
+ });
748
+ if (!res.ok)
749
+ return [];
750
+ const data = (await res.json());
751
+ const rows = data.data;
752
+ if (!Array.isArray(rows))
753
+ return [];
754
+ const byModel = {};
755
+ for (const row of rows) {
756
+ const model = typeof row.model === 'string' ? row.model : 'unknown';
757
+ if (!byModel[model]) {
758
+ byModel[model] = { model, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
759
+ }
760
+ const m = byModel[model];
761
+ m.inputTokens += typeof row.input_tokens === 'number' ? row.input_tokens : 0;
762
+ m.outputTokens += typeof row.output_tokens === 'number' ? row.output_tokens : 0;
763
+ m.cacheCreationTokens += typeof row.cache_creation_input_tokens === 'number' ? row.cache_creation_input_tokens : 0;
764
+ m.cacheReadTokens += typeof row.cache_read_input_tokens === 'number' ? row.cache_read_input_tokens : 0;
765
+ }
766
+ return Object.values(byModel);
767
+ }
768
+ catch {
769
+ return [];
770
+ }
771
+ }
772
+ async function fetchClaudeAdminCost(adminKey, startDate, endDate) {
773
+ try {
774
+ const params = new URLSearchParams({
775
+ group_by: 'model',
776
+ start_date: startDate,
777
+ end_date: endDate,
778
+ interval: 'month',
779
+ });
780
+ const res = await fetch(`https://api.anthropic.com/v1/organizations/cost_report?${params.toString()}`, {
781
+ headers: {
782
+ 'x-api-key': adminKey,
783
+ 'anthropic-version': '2023-06-01',
784
+ },
785
+ });
786
+ if (!res.ok)
787
+ return [];
788
+ const data = (await res.json());
789
+ const rows = data.data;
790
+ if (!Array.isArray(rows))
791
+ return [];
792
+ const byModel = {};
793
+ for (const row of rows) {
794
+ const model = typeof row.model === 'string' ? row.model : 'unknown';
795
+ if (!byModel[model]) {
796
+ byModel[model] = { model, costUsd: 0 };
797
+ }
798
+ byModel[model].costUsd += typeof row.cost_usd === 'number' ? row.cost_usd : 0;
799
+ }
800
+ return Object.values(byModel);
801
+ }
802
+ catch {
803
+ return [];
804
+ }
805
+ }
542
806
  export async function fetchClaudeAiCosts() {
543
807
  const period = formatPeriod();
544
808
  try {
@@ -607,13 +871,50 @@ export async function fetchClaudeAiCosts() {
607
871
  spentCents,
608
872
  limitCents,
609
873
  orgName: org.name,
874
+ planType: org.planType,
610
875
  };
876
+ const now = new Date();
877
+ const startDate = localDateStr(new Date(now.getFullYear(), now.getMonth(), 1));
878
+ const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
879
+ const endDate = localDateStr(nextMonth);
880
+ let models = [];
881
+ let totalInput = 0;
882
+ let totalOutput = 0;
883
+ let totalCacheW = 0;
884
+ let totalCacheR = 0;
885
+ if (config.claudeAdminApiKey) {
886
+ const [usageRows, costRows] = await Promise.all([
887
+ fetchClaudeAdminUsage(config.claudeAdminApiKey, startDate, endDate),
888
+ fetchClaudeAdminCost(config.claudeAdminApiKey, startDate, endDate),
889
+ ]);
890
+ if (usageRows.length > 0) {
891
+ const costMap = new Map(costRows.map((c) => [c.model, c.costUsd]));
892
+ for (const u of usageRows) {
893
+ totalInput += u.inputTokens;
894
+ totalOutput += u.outputTokens;
895
+ totalCacheW += u.cacheCreationTokens;
896
+ totalCacheR += u.cacheReadTokens;
897
+ models.push({
898
+ model: u.model,
899
+ inputTokens: u.inputTokens,
900
+ outputTokens: u.outputTokens,
901
+ cacheWriteTokens: u.cacheCreationTokens,
902
+ cacheReadTokens: u.cacheReadTokens,
903
+ costUsd: costMap.get(u.model) ?? 0,
904
+ });
905
+ }
906
+ models.sort((a, b) => b.costUsd - a.costUsd);
907
+ }
908
+ }
611
909
  return {
612
910
  tool: 'Claude.ai',
613
911
  totalCostUsd: spentCents / 100,
614
- totalInputTokens: 0,
615
- totalOutputTokens: 0,
616
- models: [],
912
+ totalInputTokens: totalInput,
913
+ totalOutputTokens: totalOutput,
914
+ totalCacheWriteTokens: totalCacheW > 0 ? totalCacheW : undefined,
915
+ totalCacheReadTokens: totalCacheR > 0 ? totalCacheR : undefined,
916
+ planType: org.planType,
917
+ models,
617
918
  claudeAi,
618
919
  period,
619
920
  };
@@ -636,11 +937,11 @@ export async function fetchClaudeAiCosts() {
636
937
  totalOutputTokens: 0,
637
938
  models: [],
638
939
  period,
639
- error: err instanceof Error ? err.message : String(err),
940
+ error: extractErrorMessage(err),
640
941
  };
641
942
  }
642
943
  }
643
- export async function fetchAllCosts(disabledCostTools) {
944
+ export async function fetchAllCosts(disabledCostTools, onTool) {
644
945
  const now = new Date();
645
946
  const monthStart = localDateStr(new Date(now.getFullYear(), now.getMonth(), 1));
646
947
  const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
@@ -654,25 +955,26 @@ export async function fetchAllCosts(disabledCostTools) {
654
955
  fetchers.push({ tool: 'Claude Code', promise: fetchClaudeCodeCosts() });
655
956
  if (!skip.has('Cursor'))
656
957
  fetchers.push({ tool: 'Cursor', promise: fetchCursorCosts() });
657
- const results = await Promise.allSettled(fetchers.map((f) => f.promise));
658
- const tools = [];
659
- for (let i = 0; i < fetchers.length; i++) {
660
- const r = results[i];
661
- if (r.status === 'fulfilled') {
662
- tools.push(r.value);
663
- }
664
- else {
665
- tools.push({
666
- tool: fetchers[i].tool,
667
- totalCostUsd: 0,
668
- totalInputTokens: 0,
669
- totalOutputTokens: 0,
670
- models: [],
671
- period: formatPeriod(),
672
- error: r.reason?.message ?? String(r.reason),
673
- });
674
- }
675
- }
958
+ // Settle each fetcher independently and stream updates via onTool as they
959
+ // resolve, so the UI can render Claude Code (fast/local) immediately
960
+ // without waiting for Cursor (slow/remote).
961
+ const wrapped = fetchers.map((f) => f.promise.then((value) => {
962
+ onTool?.(value);
963
+ return value;
964
+ }, (reason) => {
965
+ const errorSummary = {
966
+ tool: f.tool,
967
+ totalCostUsd: 0,
968
+ totalInputTokens: 0,
969
+ totalOutputTokens: 0,
970
+ models: [],
971
+ period: formatPeriod(),
972
+ error: extractErrorMessage(reason),
973
+ };
974
+ onTool?.(errorSummary);
975
+ return errorSummary;
976
+ }));
977
+ const tools = await Promise.all(wrapped);
676
978
  return {
677
979
  tools,
678
980
  month: monthLabel,