@robhowley/pi-openrouter 0.1.0 → 0.3.1

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.
@@ -1,5 +1,13 @@
1
1
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
2
- import type { UsageSummary } from './types.js';
2
+ import type { ModelStats, ProviderStats, TokenStats, UsageSummary } from './types.js';
3
+
4
+ /** Convert a Date to YYYY-MM-DD string in local timezone (matching API format) */
5
+ function localISODate(date: Date): string {
6
+ const year = date.getFullYear();
7
+ const month = String(date.getMonth() + 1).padStart(2, '0');
8
+ const day = String(date.getDate()).padStart(2, '0');
9
+ return `${year}-${month}-${day}`;
10
+ }
3
11
 
4
12
  export function aggregateUsage(
5
13
  credits: { totalUsage: number; totalCredits?: number },
@@ -12,32 +20,24 @@ export function aggregateUsage(
12
20
  startOfWeek.setDate(startOfWeek.getDate() - 7);
13
21
 
14
22
  const weekData = analytics.filter((d) => {
15
- const ts = new Date(d.date);
16
- return ts >= startOfWeek;
23
+ // API dates are YYYY-MM-DD; compare by local date boundary
24
+ return d.date >= localISODate(startOfWeek);
17
25
  });
18
26
 
19
27
  const todayData = analytics.filter((d) => {
20
- const ts = new Date(d.date);
21
- return ts >= startOfDay;
28
+ // API dates are YYYY-MM-DD; compare by local date boundary
29
+ return d.date >= localISODate(startOfDay);
22
30
  });
23
31
 
24
32
  const week = sumSpend(weekData);
25
33
  const today = sumSpend(todayData);
26
34
  const month = credits.totalUsage;
27
35
 
28
- // Top models by 7d spend
29
- const modelSpend7d = aggregateByModel(weekData);
30
- const topModels7d = Object.entries(modelSpend7d)
31
- .map(([name, spend]) => ({ name, spend }))
32
- .sort((a, b) => b.spend - a.spend)
33
- .slice(0, 3);
34
-
35
- // Top models by 30d spend
36
- const modelSpend30d = aggregateByModel(analytics);
37
- const topModels30d = Object.entries(modelSpend30d)
38
- .map(([name, spend]) => ({ name, spend }))
39
- .sort((a, b) => b.spend - a.spend)
40
- .slice(0, 3);
36
+ // Build model stats for both 7d and 30d windows
37
+ const modelStatsMap = buildModelStats(weekData, analytics);
38
+ const topModels = Array.from(modelStatsMap.values())
39
+ .sort((a, b) => b.spend30d - a.spend30d)
40
+ .slice(0, 10);
41
41
 
42
42
  return {
43
43
  today,
@@ -45,12 +45,11 @@ export function aggregateUsage(
45
45
  month,
46
46
  cap: credits.totalCredits ?? 0,
47
47
  burnRate: (week / 7) * 30,
48
- topModels7d,
49
- topModels30d,
50
- byModel: aggregateByModel(analytics),
51
- byKey: aggregateByProvider(analytics),
48
+ topModels,
49
+ byProvider: buildProviderStats(analytics),
52
50
  byDay: aggregateByDay(analytics),
53
51
  timestamp,
52
+ hasActivityData: true, // aggregateUsage is only called when analytics data is available
54
53
  };
55
54
  }
56
55
 
@@ -58,24 +57,69 @@ function sumSpend(data: ActivityItem[]): number {
58
57
  return data.reduce((sum, d) => sum + d.usage, 0);
59
58
  }
60
59
 
61
- function aggregateByModel(data: ActivityItem[]): Record<string, number> {
60
+ function aggregateTokens(data: ActivityItem[]): TokenStats {
62
61
  return data.reduce(
63
62
  (acc, d) => {
64
- acc[d.model] = (acc[d.model] || 0) + d.usage;
63
+ acc.input += d.promptTokens || 0;
64
+ acc.output += d.completionTokens || 0;
65
+ acc.reasoning += d.reasoningTokens || 0;
66
+ acc.total += (d.promptTokens || 0) + (d.completionTokens || 0) + (d.reasoningTokens || 0);
65
67
  return acc;
66
68
  },
67
- {} as Record<string, number>,
69
+ { input: 0, output: 0, reasoning: 0, total: 0 } as TokenStats,
68
70
  );
69
71
  }
70
72
 
71
- function aggregateByProvider(data: ActivityItem[]): Record<string, number> {
72
- return data.reduce(
73
- (acc, d) => {
74
- acc[d.providerName] = (acc[d.providerName] || 0) + d.usage;
75
- return acc;
76
- },
77
- {} as Record<string, number>,
78
- );
73
+ function aggregateRequests(data: ActivityItem[]): number {
74
+ return data.reduce((sum, d) => sum + (d.requests || 0), 0);
75
+ }
76
+
77
+ function buildModelStats(data7d: ActivityItem[], data30d: ActivityItem[]): Map<string, ModelStats> {
78
+ const all = new Map<string, ModelStats>();
79
+ const modelNames = new Set<string>();
80
+
81
+ // Collect all unique model names from both time windows
82
+ for (const d of data30d) modelNames.add(d.model);
83
+ for (const d of data7d) modelNames.add(d.model);
84
+
85
+ for (const name of modelNames) {
86
+ const data7dForModel = data7d.filter((d) => d.model === name);
87
+ const data30dForModel = data30d.filter((d) => d.model === name);
88
+
89
+ all.set(name, {
90
+ name,
91
+ spend7d: data7dForModel.reduce((s, d) => s + d.usage, 0),
92
+ spend30d: data30dForModel.reduce((s, d) => s + d.usage, 0),
93
+ tokens7d: aggregateTokens(data7dForModel),
94
+ tokens30d: aggregateTokens(data30dForModel),
95
+ requests7d: aggregateRequests(data7dForModel),
96
+ requests30d: aggregateRequests(data30dForModel),
97
+ });
98
+ }
99
+
100
+ return all;
101
+ }
102
+
103
+ function buildProviderStats(data: ActivityItem[]): ProviderStats[] {
104
+ const byName = new Map<string, ActivityItem[]>();
105
+
106
+ for (const d of data) {
107
+ const existing = byName.get(d.providerName) || [];
108
+ existing.push(d);
109
+ byName.set(d.providerName, existing);
110
+ }
111
+
112
+ const stats: ProviderStats[] = [];
113
+ for (const [name, items] of byName) {
114
+ stats.push({
115
+ name,
116
+ spend: items.reduce((s, d) => s + d.usage, 0),
117
+ tokens: aggregateTokens(items),
118
+ requests: aggregateRequests(items),
119
+ });
120
+ }
121
+
122
+ return stats.sort((a, b) => b.spend - a.spend);
79
123
  }
80
124
 
81
125
  function aggregateByDay(data: ActivityItem[]): Record<string, number> {
@@ -10,8 +10,6 @@ import { AuthError } from './client.js';
10
10
  import { UsageOverlayComponent } from './overlay.js';
11
11
 
12
12
  export default function (pi: ExtensionAPI) {
13
- startBackgroundRefresh();
14
-
15
13
  pi.on('session_start', async (_event, ctx) => {
16
14
  ctx.ui.notify('OpenRouter extension loaded', 'info');
17
15
  });
@@ -24,6 +22,7 @@ export default function (pi: ExtensionAPI) {
24
22
  description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
25
23
  getArgumentCompletions: () => null,
26
24
  handler: async (args, ctx) => {
25
+ startBackgroundRefresh(); // Start cache refresh on first use
27
26
  const subcommand = args.trim() || undefined;
28
27
  await showUsageOverlay(ctx, subcommand);
29
28
  },
@@ -47,9 +46,14 @@ async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
47
46
 
48
47
  try {
49
48
  summary = await fetchAndAggregate();
50
- usageCache.set('usage', summary);
49
+ if (!summary) {
50
+ error =
51
+ 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.';
52
+ } else {
53
+ usageCache.set('usage', summary);
54
+ }
51
55
 
52
- await showOverlay(ctx, summary, null, 0);
56
+ await showOverlay(ctx, summary, error, 0);
53
57
  } catch (error_) {
54
58
  const err = error_ as Error;
55
59
  error =
@@ -89,6 +93,11 @@ async function showOverlay(
89
93
  },
90
94
  };
91
95
  },
92
- { overlay: true },
96
+ {
97
+ overlay: true,
98
+ overlayOptions: {
99
+ width: 100,
100
+ },
101
+ },
93
102
  );
94
103
  }