@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.
- package/extensions/openrouter/__tests__/format.test.ts +161 -53
- package/extensions/openrouter/cache.ts +21 -10
- package/extensions/openrouter/chart.ts +158 -0
- package/extensions/openrouter/client.ts +10 -6
- package/extensions/openrouter/format.ts +77 -33
- package/extensions/openrouter/index.ts +14 -5
- package/extensions/openrouter/overlay.ts +284 -157
- package/extensions/openrouter/types.ts +28 -5
- package/package.json +3 -2
|
@@ -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.
|
|
77
|
-
expect(result.
|
|
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-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
expect(result.
|
|
125
|
-
|
|
126
|
-
expect(
|
|
127
|
-
expect(
|
|
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
|
|
169
|
+
it('should include 30d model data', () => {
|
|
131
170
|
const credits = {
|
|
132
|
-
totalUsage:
|
|
133
|
-
totalCredits:
|
|
171
|
+
totalUsage: 100,
|
|
172
|
+
totalCredits: 200,
|
|
134
173
|
};
|
|
135
|
-
|
|
174
|
+
// Data from recent dates for 30d
|
|
136
175
|
const analytics: ActivityItem[] = [
|
|
137
176
|
{
|
|
138
|
-
date:
|
|
177
|
+
date: '2026-04-15',
|
|
139
178
|
model: 'gpt-4',
|
|
140
179
|
modelPermaslug: 'gpt-4-perma',
|
|
141
180
|
endpointId: 'ep-1',
|
|
142
|
-
usage:
|
|
181
|
+
usage: 50.0,
|
|
143
182
|
byokUsageInference: 0,
|
|
144
|
-
requests:
|
|
145
|
-
promptTokens:
|
|
146
|
-
completionTokens:
|
|
183
|
+
requests: 10,
|
|
184
|
+
promptTokens: 1000,
|
|
185
|
+
completionTokens: 100,
|
|
147
186
|
reasoningTokens: 0,
|
|
148
187
|
providerName: 'openai',
|
|
149
188
|
},
|
|
150
189
|
{
|
|
151
|
-
date:
|
|
190
|
+
date: '2026-04-20',
|
|
152
191
|
model: 'claude-3',
|
|
153
192
|
modelPermaslug: 'claude-3-perma',
|
|
154
193
|
endpointId: 'ep-2',
|
|
155
|
-
usage:
|
|
194
|
+
usage: 30.0,
|
|
156
195
|
byokUsageInference: 0,
|
|
157
|
-
requests:
|
|
158
|
-
promptTokens:
|
|
159
|
-
completionTokens:
|
|
196
|
+
requests: 5,
|
|
197
|
+
promptTokens: 500,
|
|
198
|
+
completionTokens: 50,
|
|
160
199
|
reasoningTokens: 0,
|
|
161
|
-
providerName: '
|
|
200
|
+
providerName: 'anthropic',
|
|
162
201
|
},
|
|
163
202
|
];
|
|
164
203
|
|
|
165
204
|
const result = aggregateUsage(credits, analytics);
|
|
166
205
|
|
|
167
|
-
//
|
|
168
|
-
expect(result.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
expect(result.
|
|
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
|
|
214
|
+
it('should aggregate provider stats with tokens', () => {
|
|
177
215
|
const credits = {
|
|
178
|
-
totalUsage:
|
|
179
|
-
totalCredits:
|
|
216
|
+
totalUsage: 10,
|
|
217
|
+
totalCredits: 100,
|
|
180
218
|
};
|
|
181
|
-
|
|
219
|
+
const date = '2026-05-01';
|
|
182
220
|
const analytics: ActivityItem[] = [
|
|
183
221
|
{
|
|
184
|
-
date:
|
|
222
|
+
date: date,
|
|
185
223
|
model: 'gpt-4',
|
|
186
224
|
modelPermaslug: 'gpt-4-perma',
|
|
187
225
|
endpointId: 'ep-1',
|
|
188
|
-
usage:
|
|
226
|
+
usage: 5.0,
|
|
189
227
|
byokUsageInference: 0,
|
|
190
|
-
requests:
|
|
191
|
-
promptTokens:
|
|
192
|
-
completionTokens:
|
|
228
|
+
requests: 5,
|
|
229
|
+
promptTokens: 100,
|
|
230
|
+
completionTokens: 50,
|
|
193
231
|
reasoningTokens: 0,
|
|
194
232
|
providerName: 'openai',
|
|
195
233
|
},
|
|
196
234
|
{
|
|
197
|
-
date:
|
|
235
|
+
date: date,
|
|
198
236
|
model: 'claude-3',
|
|
199
237
|
modelPermaslug: 'claude-3-perma',
|
|
200
238
|
endpointId: 'ep-2',
|
|
201
|
-
usage:
|
|
239
|
+
usage: 3.0,
|
|
202
240
|
byokUsageInference: 0,
|
|
203
|
-
requests:
|
|
204
|
-
promptTokens:
|
|
205
|
-
completionTokens:
|
|
241
|
+
requests: 3,
|
|
242
|
+
promptTokens: 60,
|
|
243
|
+
completionTokens: 30,
|
|
206
244
|
reasoningTokens: 0,
|
|
207
|
-
providerName: '
|
|
245
|
+
providerName: 'openai', // Same provider, different endpoint
|
|
208
246
|
},
|
|
209
247
|
];
|
|
210
248
|
|
|
211
249
|
const result = aggregateUsage(credits, analytics);
|
|
212
250
|
|
|
213
|
-
//
|
|
214
|
-
expect(result.
|
|
215
|
-
expect(result.
|
|
216
|
-
expect(result.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
consecutiveFailures
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
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
|
|
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
|
|
30
|
+
const response = await client.analytics.getUserActivity();
|
|
27
31
|
return response.data;
|
|
28
32
|
} catch (err) {
|
|
29
33
|
throw mapSdkError(err);
|