@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,8 +1,44 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { aggregateUsage } from '../format.js';
3
+ import { renderSpendSparkline } from '../chart.js';
3
4
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
4
5
 
5
6
  describe('aggregateUsage', () => {
7
+ it('should correctly aggregate today spend regardless of timezone', () => {
8
+ const credits = {
9
+ totalUsage: 10,
10
+ totalCredits: 100,
11
+ };
12
+
13
+ // Get today's date in YYYY-MM-DD format using LOCAL date
14
+ // This matches how the API returns dates (YYYY-MM-DD without timezone)
15
+ const now = new Date();
16
+ const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
17
+
18
+ const analytics: ActivityItem[] = [
19
+ {
20
+ date: todayStr,
21
+ model: 'gpt-4',
22
+ modelPermaslug: 'gpt-4-perma',
23
+ endpointId: 'ep-1',
24
+ usage: 6.55,
25
+ byokUsageInference: 0,
26
+ requests: 10,
27
+ promptTokens: 1000,
28
+ completionTokens: 100,
29
+ reasoningTokens: 0,
30
+ providerName: 'openai',
31
+ },
32
+ ];
33
+
34
+ const result = aggregateUsage(credits, analytics);
35
+
36
+ // Today should include data from todayStr (date strings compared directly)
37
+ // This was previously a bug where dates were parsed as UTC timestamps
38
+ // causing timezone-related filtering errors
39
+ expect(result.today).toBe(6.55);
40
+ });
41
+
6
42
  it('should calculate from analytics', () => {
7
43
  const credits = {
8
44
  totalUsage: 38.42,
@@ -73,9 +109,8 @@ describe('aggregateUsage', () => {
73
109
  expect(result.month).toBe(18.21);
74
110
  expect(result.week).toBe(0);
75
111
  expect(result.today).toBe(0);
76
- expect(result.topModels7d).toEqual([]);
77
- expect(result.byModel).toEqual({});
78
- expect(result.byKey).toEqual({});
112
+ expect(result.topModels).toEqual([]);
113
+ expect(result.byProvider).toEqual([]);
79
114
  expect(result.byDay).toEqual({});
80
115
  });
81
116
 
@@ -85,7 +120,7 @@ describe('aggregateUsage', () => {
85
120
  totalCredits: 100,
86
121
  };
87
122
  // Use a fixed date that's definitely in the past
88
- const date = '2026-05-03';
123
+ const date = '2026-05-04';
89
124
  const analytics: ActivityItem[] = [
90
125
  {
91
126
  date: date,
@@ -117,102 +152,175 @@ describe('aggregateUsage', () => {
117
152
 
118
153
  const result = aggregateUsage(credits, analytics);
119
154
 
120
- expect(result.byModel).toEqual({
121
- 'gpt-4': 5.0,
122
- 'claude-3': 3.0,
123
- });
124
- expect(result.topModels7d).toHaveLength(2);
125
- const first = result.topModels7d[0]!;
126
- expect(first.name).toBe('gpt-4');
127
- expect(first.spend).toBe(5.0);
155
+ // topModels should be populated with model stats
156
+ expect(result.topModels).toHaveLength(2);
157
+ expect(result.topModels[0]?.name).toBe('gpt-4');
158
+ expect(result.topModels[0]?.spend30d).toBe(5.0);
159
+ expect(result.topModels[0]?.tokens7d.total).toBe(150); // 100 + 50
160
+ expect(result.topModels[0]?.tokens30d.total).toBe(150);
161
+ expect(result.topModels[0]?.requests7d).toBe(5);
162
+ expect(result.topModels[0]?.requests30d).toBe(5);
163
+ expect(result.topModels[1]?.name).toBe('claude-3');
164
+ expect(result.topModels[1]?.spend30d).toBe(3.0);
165
+ expect(result.topModels[1]?.tokens7d.total).toBe(90); // 60 + 30
166
+ expect(result.topModels[1]?.tokens30d.total).toBe(90);
128
167
  });
129
168
 
130
- it('should aggregate by provider name (not endpoint ID)', () => {
169
+ it('should include 30d model data', () => {
131
170
  const credits = {
132
- totalUsage: 10,
133
- totalCredits: 100,
171
+ totalUsage: 100,
172
+ totalCredits: 200,
134
173
  };
135
- const date = '2026-05-01';
174
+ // Data from recent dates for 30d
136
175
  const analytics: ActivityItem[] = [
137
176
  {
138
- date: date,
177
+ date: '2026-04-15',
139
178
  model: 'gpt-4',
140
179
  modelPermaslug: 'gpt-4-perma',
141
180
  endpointId: 'ep-1',
142
- usage: 5.0,
181
+ usage: 50.0,
143
182
  byokUsageInference: 0,
144
- requests: 5,
145
- promptTokens: 100,
146
- completionTokens: 50,
183
+ requests: 10,
184
+ promptTokens: 1000,
185
+ completionTokens: 100,
147
186
  reasoningTokens: 0,
148
187
  providerName: 'openai',
149
188
  },
150
189
  {
151
- date: date,
190
+ date: '2026-04-20',
152
191
  model: 'claude-3',
153
192
  modelPermaslug: 'claude-3-perma',
154
193
  endpointId: 'ep-2',
155
- usage: 3.0,
194
+ usage: 30.0,
156
195
  byokUsageInference: 0,
157
- requests: 3,
158
- promptTokens: 60,
159
- completionTokens: 30,
196
+ requests: 5,
197
+ promptTokens: 500,
198
+ completionTokens: 50,
160
199
  reasoningTokens: 0,
161
- providerName: 'openai', // Same provider, different endpoint
200
+ providerName: 'anthropic',
162
201
  },
163
202
  ];
164
203
 
165
204
  const result = aggregateUsage(credits, analytics);
166
205
 
167
- // byKey should use providerName, not endpointId
168
- expect(result.byKey).toEqual({
169
- openai: 8.0, // Total from both endpoints
170
- });
171
- // Should NOT contain endpoint IDs
172
- expect(result.byKey).not.toHaveProperty('ep-1');
173
- expect(result.byKey).not.toHaveProperty('ep-2');
206
+ // topModels should be populated with 30d data
207
+ expect(result.topModels).toHaveLength(2);
208
+ expect(result.topModels[0]?.name).toBe('gpt-4');
209
+ expect(result.topModels[0]?.spend30d).toBe(50.0);
210
+ expect(result.topModels[1]?.name).toBe('claude-3');
211
+ expect(result.topModels[1]?.spend30d).toBe(30.0);
174
212
  });
175
213
 
176
- it('should include 30d model data', () => {
214
+ it('should aggregate provider stats with tokens', () => {
177
215
  const credits = {
178
- totalUsage: 100,
179
- totalCredits: 200,
216
+ totalUsage: 10,
217
+ totalCredits: 100,
180
218
  };
181
- // Data from recent dates for 30d
219
+ const date = '2026-05-01';
182
220
  const analytics: ActivityItem[] = [
183
221
  {
184
- date: '2026-04-15',
222
+ date: date,
185
223
  model: 'gpt-4',
186
224
  modelPermaslug: 'gpt-4-perma',
187
225
  endpointId: 'ep-1',
188
- usage: 50.0,
226
+ usage: 5.0,
189
227
  byokUsageInference: 0,
190
- requests: 10,
191
- promptTokens: 1000,
192
- completionTokens: 100,
228
+ requests: 5,
229
+ promptTokens: 100,
230
+ completionTokens: 50,
193
231
  reasoningTokens: 0,
194
232
  providerName: 'openai',
195
233
  },
196
234
  {
197
- date: '2026-04-20',
235
+ date: date,
198
236
  model: 'claude-3',
199
237
  modelPermaslug: 'claude-3-perma',
200
238
  endpointId: 'ep-2',
201
- usage: 30.0,
239
+ usage: 3.0,
202
240
  byokUsageInference: 0,
203
- requests: 5,
204
- promptTokens: 500,
205
- completionTokens: 50,
241
+ requests: 3,
242
+ promptTokens: 60,
243
+ completionTokens: 30,
206
244
  reasoningTokens: 0,
207
- providerName: 'anthropic',
245
+ providerName: 'openai', // Same provider, different endpoint
208
246
  },
209
247
  ];
210
248
 
211
249
  const result = aggregateUsage(credits, analytics);
212
250
 
213
- // topModels30d should be populated
214
- expect(result.topModels30d).toHaveLength(2);
215
- expect(result.topModels30d[0]).toEqual({ name: 'gpt-4', spend: 50.0 });
216
- expect(result.topModels30d[1]).toEqual({ name: 'claude-3', spend: 30.0 });
251
+ // byProvider should use providerName, aggregated correctly
252
+ expect(result.byProvider).toHaveLength(1);
253
+ expect(result.byProvider[0]?.name).toBe('openai');
254
+ expect(result.byProvider[0]?.spend).toBe(8.0);
255
+ // Token counts should be aggregated
256
+ expect(result.byProvider[0]?.tokens.total).toBe(240); // 100 + 50 + 60 + 30
257
+ expect(result.byProvider[0]?.tokens.input).toBe(160); // 100 + 60
258
+ expect(result.byProvider[0]?.tokens.output).toBe(80); // 50 + 30
259
+ expect(result.byProvider[0]?.requests).toBe(8); // 5 + 3
260
+ });
261
+ });
262
+
263
+ describe('renderSpendSparkline', () => {
264
+ it('should generate chart with < 30 days (should pad with zeros)', () => {
265
+ const byDay = {
266
+ '2026-05-01': 10.5,
267
+ '2026-05-02': 15.25,
268
+ '2026-05-03': 8.0,
269
+ };
270
+ const chartLines = renderSpendSparkline(byDay, 60);
271
+
272
+ // 9 bar + 1 separator + 2 label lines = 12
273
+ expect(chartLines).toHaveLength(12);
274
+ // First line should have bars (Unicode block characters)
275
+ expect(chartLines[0]).toMatch(/[█]/);
276
+ });
277
+
278
+ it('should generate chart with exactly 30 days', () => {
279
+ const byDay: Record<string, number> = {};
280
+ for (let i = 1; i <= 30; i++) {
281
+ const day = i < 10 ? `0${i}` : `${i}`;
282
+ byDay[`2026-05-${day}`] = i * 2.5;
283
+ }
284
+
285
+ const chartLines = renderSpendSparkline(byDay, 60);
286
+ // 9 bar + 1 separator + 2 label lines = 12
287
+ expect(chartLines).toHaveLength(12);
288
+ // First line should have bars
289
+ expect(chartLines[0]).toMatch(/[█]/);
290
+ });
291
+
292
+ it('should respect width constraints', () => {
293
+ const byDay = { '2026-05-01': 10 };
294
+ const narrowChart = renderSpendSparkline(byDay, 30);
295
+ const wideChart = renderSpendSparkline(byDay, 80);
296
+
297
+ // 9 bar + 1 separator + 2 label lines = 12
298
+ expect(narrowChart).toHaveLength(12);
299
+ expect(wideChart).toHaveLength(12);
300
+ // Bar width should be constrained
301
+ expect(narrowChart[0]!.length).toBeLessThanOrEqual(67);
302
+ expect(wideChart[0]!.length).toBeGreaterThanOrEqual(narrowChart[0]!.length);
303
+ });
304
+
305
+ it('should produce valid x-axis labels', () => {
306
+ const byDay: Record<string, number> = {};
307
+ for (let i = 1; i <= 30; i++) {
308
+ const day = i < 10 ? `0${i}` : `${i}`;
309
+ byDay[`2026-05-${day}`] = i * 2.5;
310
+ }
311
+
312
+ const chartLines = renderSpendSparkline(byDay, 80);
313
+ const dayNumbersLine = chartLines[11]; // day numbers are at line 11
314
+
315
+ expect(dayNumbersLine).toBeDefined();
316
+ // Should contain day numbers for positions 0, 5, 10, 15, 20, 25, 29 (30 bars total)
317
+ // Each day number is 2 chars, so day 1=col4, day 6=col12, day 11=col20, etc
318
+ expect(dayNumbersLine).toContain('01'); // Day 0 (29 days ago from 05-30)
319
+ expect(dayNumbersLine).toContain('06'); // Day 5
320
+ expect(dayNumbersLine).toContain('11'); // Day 10
321
+ expect(dayNumbersLine).toContain('16'); // Day 15
322
+ expect(dayNumbersLine).toContain('21'); // Day 20
323
+ expect(dayNumbersLine).toContain('26'); // Day 25
324
+ expect(dayNumbersLine).toContain('30'); // Day 29 (today)
217
325
  });
218
326
  });
@@ -57,16 +57,25 @@ function getBackoffInterval(): number {
57
57
  return BACKGROUND_REFRESH_INTERVAL_MS * Math.pow(2, backoffMultiplier);
58
58
  }
59
59
 
60
- export async function fetchAndAggregate(): Promise<UsageSummary> {
60
+ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
61
61
  const credits = await getCredits();
62
+ if (!credits) return null;
62
63
  let analytics: ActivityItem[] | null = null;
64
+ let hasActivityData = true;
63
65
  try {
64
66
  analytics = await getActivity();
67
+ if (!analytics) hasActivityData = false;
65
68
  } catch (err) {
66
- console.log('Activity fetch failed (management key required):', err);
69
+ // getActivity() requires a management key; suppress this expected error
70
+ if (!(err instanceof Error) || !err.message.includes('management key')) {
71
+ console.log('Activity fetch failed:', err);
72
+ }
73
+ hasActivityData = false;
67
74
  }
68
75
  const timestamp = Date.now();
69
- return aggregateUsage(credits, analytics ?? [], timestamp);
76
+ const summary = aggregateUsage(credits, analytics ?? [], timestamp);
77
+ summary.hasActivityData = hasActivityData;
78
+ return summary;
70
79
  }
71
80
 
72
81
  function scheduleRefresh(): void {
@@ -75,13 +84,15 @@ function scheduleRefresh(): void {
75
84
  refreshInterval = setInterval(async () => {
76
85
  try {
77
86
  const summary = await fetchAndAggregate();
78
- usageCache.set('usage', summary);
79
-
80
- // Reset failure count on success and restart with normal interval
81
- if (consecutiveFailures > 0) {
82
- consecutiveFailures = 0;
83
- stopBackgroundRefresh();
84
- scheduleRefresh();
87
+ if (summary) {
88
+ usageCache.set('usage', summary);
89
+
90
+ // Reset failure count on success and restart with normal interval
91
+ if (consecutiveFailures > 0) {
92
+ consecutiveFailures = 0;
93
+ stopBackgroundRefresh();
94
+ scheduleRefresh();
95
+ }
85
96
  }
86
97
  } catch (err) {
87
98
  consecutiveFailures++;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Bar chart rendering for daily spend data
3
+ * @param byDay - Record mapping date strings (YYYY-MM-DD) to spend amounts
4
+ * @param _width - Maximum width for the chart (including padding)
5
+ * @returns Formatted ASCII bar chart string (for text-table row)
6
+ */
7
+ export function renderSpendBarChart(byDay: Record<string, number>, _width: number): string {
8
+ if (Object.keys(byDay).length === 0) {
9
+ return 'No spend data';
10
+ }
11
+
12
+ // Find the date range from the data (use most recent 30 days worth of dates)
13
+ const sortedKeys = Object.keys(byDay).sort();
14
+ const latestDateStr = sortedKeys[sortedKeys.length - 1]!;
15
+
16
+ // Parse date components (handle both YYYY-MM-DD and YYYY-MM-DDTHH:mm:ssZ)
17
+ const dateMatch = latestDateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
18
+ if (!dateMatch) {
19
+ return 'Invalid date format';
20
+ }
21
+
22
+ // Build 30-day window ending on the latest date in the data
23
+ const [, yearStr, monthStr, dayStr] = dateMatch;
24
+ if (!yearStr || !monthStr || !dayStr) {
25
+ return 'Invalid date format';
26
+ }
27
+ const year = parseInt(yearStr, 10);
28
+ const month = parseInt(monthStr, 10);
29
+ const day = parseInt(dayStr, 10);
30
+
31
+ const values: number[] = [];
32
+ const dates: string[] = [];
33
+
34
+ // Generate 30 days of dates ending on latestDate
35
+ for (let i = 29; i >= 0; i--) {
36
+ const d = new Date(year, month - 1, day);
37
+ d.setDate(d.getDate() - i);
38
+
39
+ const y = d.getFullYear();
40
+ const m = String(d.getMonth() + 1).padStart(2, '0');
41
+ const dayStr = String(d.getDate()).padStart(2, '0');
42
+ const dateKey = `${y}-${m}-${dayStr}`;
43
+
44
+ dates.push(dateKey);
45
+ // Look up value in byDay (try exact match and time-suffixed variants)
46
+ let value = byDay[dateKey];
47
+ if (value === undefined) {
48
+ // Try with time suffixes
49
+ value = byDay[dateKey + 'T00:00:00Z'] ?? byDay[dateKey + ' 00:00:00'];
50
+ }
51
+ values.push(value ?? 0);
52
+ }
53
+
54
+ const max = Math.max(...values);
55
+
56
+ if (max === 0) {
57
+ return 'No spend';
58
+ }
59
+
60
+ // Build 9-row horizontal bar chart (top to bottom)
61
+ // Each bar is 2 chars: "█ " (bar + breathing space)
62
+ const chartHeight = 9;
63
+ const valueStep = Math.ceil(max / chartHeight);
64
+ const lines: string[] = [];
65
+ const chartWidth = 30 * 2; // 30 bars with space between
66
+
67
+ // Draw rows from top (highest value) to bottom (lowest - the x-axis)
68
+ for (let row = chartHeight - 1; row >= 0; row--) {
69
+ const rowValue = row * valueStep;
70
+ const label = String(rowValue).padStart(3);
71
+
72
+ let line = ` ${label} |`;
73
+
74
+ // For each bar: fill if the bar's height reaches above current row
75
+ // Add space after each bar for breathing room
76
+ for (const v of values) {
77
+ const barHeight = Math.ceil((v / max) * chartHeight);
78
+ line += barHeight > row ? '█' : ' ';
79
+ line += ' '; // Breathing space between bars
80
+ }
81
+
82
+ lines.push(line);
83
+ }
84
+
85
+ // Separator line - spans full width including spaces between bars
86
+ lines.push(' +' + '─'.repeat(chartWidth));
87
+
88
+ // Month label line - mark month changes
89
+ const firstMonth = dates[0]!.slice(5, 7);
90
+ const lastMonth = dates[dates.length - 1]!.slice(5, 7);
91
+
92
+ let monthLine = ' ';
93
+ // Find where month changes
94
+ const monthChangeIndex = dates.findIndex((d, i) => i > 0 && d.slice(5, 7) !== firstMonth);
95
+
96
+ if (firstMonth === lastMonth) {
97
+ // All in one month - centered label
98
+ const label = getMonthName(firstMonth);
99
+ const padding = Math.floor((chartWidth - label.length) / 2);
100
+ monthLine += ' '.repeat(padding) + label + ' '.repeat(chartWidth - padding - label.length);
101
+ } else if (monthChangeIndex > 0) {
102
+ // Month transition - show first month near start, second near end
103
+ const firstLabel = getMonthName(firstMonth);
104
+ const secondLabel = getMonthName(lastMonth);
105
+ // Position labels: first at ~8 chars, second at ~52 chars
106
+ const firstPos = 4;
107
+ const secondPos = chartWidth - 8;
108
+ monthLine +=
109
+ ' '.repeat(firstPos) +
110
+ firstLabel +
111
+ ' '.repeat(secondPos - firstPos - firstLabel.length) +
112
+ secondLabel +
113
+ ' '.repeat(chartWidth - secondPos - secondLabel.length);
114
+ }
115
+
116
+ lines.push(monthLine.slice(0, 6 + chartWidth));
117
+
118
+ // Day numbers line - show every 2-3 days, aligning with bar positions
119
+ // Each day is at position i*2, so day 0=col0, day 5=col10, day 10=col20, etc
120
+ let daysLine = ' ';
121
+ for (let i = 0; i < 30; i++) {
122
+ // Show day number at every bar position
123
+ const dayNum = dates[i]!.slice(8, 10);
124
+ if (i === 0 || i === 29 || i % 5 === 0) {
125
+ // Place the 2-digit day number, it will take 2 chars which aligns perfectly
126
+ daysLine += dayNum;
127
+ } else {
128
+ daysLine += ' ';
129
+ }
130
+ }
131
+ lines.push(daysLine.slice(0, 6 + chartWidth));
132
+
133
+ return lines.join('\n');
134
+ }
135
+
136
+ function getMonthName(mm: string): string {
137
+ const months: Record<string, string> = {
138
+ '01': 'Jan',
139
+ '02': 'Feb',
140
+ '03': 'Mar',
141
+ '04': 'Apr',
142
+ '05': 'May',
143
+ '06': 'Jun',
144
+ '07': 'Jul',
145
+ '08': 'Aug',
146
+ '09': 'Sep',
147
+ '10': 'Oct',
148
+ '11': 'Nov',
149
+ '12': 'Dec',
150
+ };
151
+ return months[mm] ?? mm;
152
+ }
153
+
154
+ // Legacy export for backward compatibility
155
+ export function renderSpendSparkline(...args: Parameters<typeof renderSpendBarChart>): string[] {
156
+ const chartOutput = renderSpendBarChart(...args);
157
+ return chartOutput.split('\n').filter((line) => line.length > 0);
158
+ }
@@ -4,26 +4,30 @@ import { OpenRouter } from '@openrouter/sdk/sdk/sdk.js';
4
4
 
5
5
  let client: OpenRouter | null = null;
6
6
 
7
- function getClient(): OpenRouter {
7
+ function getClient(): OpenRouter | null {
8
8
  if (client) return client;
9
9
  const apiKey = process.env['OPENROUTER_MANAGEMENT_KEY'] || process.env['OPENROUTER_API_KEY'];
10
- if (!apiKey) throw new AuthError('OPENROUTER_API_KEY or OPENROUTER_MANAGEMENT_KEY not set');
10
+ if (!apiKey) return null;
11
11
  client = new OpenRouter({ apiKey });
12
12
  return client;
13
13
  }
14
14
 
15
- export async function getCredits(): Promise<GetCreditsResponse['data']> {
15
+ export async function getCredits(): Promise<GetCreditsResponse['data'] | null> {
16
+ const client = getClient();
17
+ if (!client) return null;
16
18
  try {
17
- const response = await getClient().credits.getCredits();
19
+ const response = await client.credits.getCredits();
18
20
  return response.data;
19
21
  } catch (err) {
20
22
  throw mapSdkError(err);
21
23
  }
22
24
  }
23
25
 
24
- export async function getActivity(): Promise<ActivityResponse['data']> {
26
+ export async function getActivity(): Promise<ActivityResponse['data'] | null> {
27
+ const client = getClient();
28
+ if (!client) return null;
25
29
  try {
26
- const response = await getClient().analytics.getUserActivity();
30
+ const response = await client.analytics.getUserActivity();
27
31
  return response.data;
28
32
  } catch (err) {
29
33
  throw mapSdkError(err);