@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,12 +1,46 @@
|
|
|
1
1
|
import { matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
|
|
2
2
|
import type { Theme } from '@mariozechner/pi-coding-agent';
|
|
3
|
-
import type { UsageSummary } from './types.js';
|
|
3
|
+
import type { ModelStats, ProviderStats, UsageSummary } from './types.js';
|
|
4
|
+
import { renderSpendBarChart } from './chart.js';
|
|
4
5
|
import { usageCache } from './cache.js';
|
|
5
6
|
|
|
6
7
|
const MIN_WIDTH = 44;
|
|
7
|
-
|
|
8
|
+
|
|
9
|
+
// Formatting utilities (shared, not class methods)
|
|
10
|
+
function fmtTokens(n: number): string {
|
|
11
|
+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
12
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
13
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
14
|
+
return n.toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fmtCount(n: number): string {
|
|
18
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
19
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
20
|
+
return n.toString();
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
export class UsageOverlayComponent {
|
|
24
|
+
// Column width constants (shared across all tables for alignment)
|
|
25
|
+
private static readonly COLS = {
|
|
26
|
+
model: 30,
|
|
27
|
+
spend: 9,
|
|
28
|
+
tokens: 9,
|
|
29
|
+
costPerM: 8,
|
|
30
|
+
reqs: 7,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
private static readonly TABLE_INNER_WIDTH =
|
|
34
|
+
UsageOverlayComponent.COLS.model +
|
|
35
|
+
2 +
|
|
36
|
+
UsageOverlayComponent.COLS.spend +
|
|
37
|
+
2 +
|
|
38
|
+
UsageOverlayComponent.COLS.tokens +
|
|
39
|
+
2 +
|
|
40
|
+
UsageOverlayComponent.COLS.costPerM +
|
|
41
|
+
2 +
|
|
42
|
+
UsageOverlayComponent.COLS.reqs;
|
|
43
|
+
|
|
10
44
|
private lines: string[];
|
|
11
45
|
private theme: Theme;
|
|
12
46
|
private onClose: () => void;
|
|
@@ -60,6 +94,7 @@ export class UsageOverlayComponent {
|
|
|
60
94
|
|
|
61
95
|
render(width: number): string[] {
|
|
62
96
|
// Center the overlay if terminal is wider
|
|
97
|
+
// Width includes 2 extra characters for visual padding (1 space on each side)
|
|
63
98
|
const padding = Math.max(0, Math.floor((width - this.width) / 2));
|
|
64
99
|
const pad = ' '.repeat(padding);
|
|
65
100
|
|
|
@@ -71,50 +106,16 @@ export class UsageOverlayComponent {
|
|
|
71
106
|
// Rebuild lines to update "last refreshed" time from fresh cached data
|
|
72
107
|
const freshSummary = usageCache.get('usage');
|
|
73
108
|
this.lines = this.buildLines(freshSummary || this.summary, this.error, this.cachedMinutesAgo);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
private calculateWidth(summary: UsageSummary | null): number {
|
|
78
|
-
if (!summary) return MIN_WIDTH;
|
|
79
|
-
|
|
80
|
-
let maxWidth = MIN_WIDTH;
|
|
81
|
-
|
|
82
|
-
// Calculate width needed for top models table
|
|
83
|
-
if (summary.topModels7d.length > 0 || summary.topModels30d.length > 0) {
|
|
84
|
-
const allModelNames = [
|
|
85
|
-
...summary.topModels7d.map((m) => m.name),
|
|
86
|
-
...summary.topModels30d.map((m) => m.name),
|
|
87
|
-
];
|
|
88
|
-
const maxModelNameLen = allModelNames.reduce((max, name) => Math.max(max, name.length), 0);
|
|
89
|
-
const amountWidth = 8; // "$X.XX" + padding
|
|
90
|
-
const rowWidth = 2 + maxModelNameLen + 2 + amountWidth + 2 + amountWidth + 2;
|
|
91
|
-
maxWidth = Math.max(maxWidth, rowWidth);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Calculate width needed for provider table
|
|
95
|
-
if (summary.byKey && Object.keys(summary.byKey).length > 0) {
|
|
96
|
-
const maxProviderLen = Object.keys(summary.byKey).reduce(
|
|
97
|
-
(max, name) => Math.max(max, name.length),
|
|
98
|
-
0,
|
|
99
|
-
);
|
|
100
|
-
const amountWidth = 8;
|
|
101
|
-
const rowWidth = 2 + maxProviderLen + 2 + amountWidth + 2;
|
|
102
|
-
maxWidth = Math.max(maxWidth, rowWidth);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Calculate width needed for by-day table
|
|
106
|
-
if (summary.byDay && Object.keys(summary.byDay).length > 0) {
|
|
107
|
-
maxWidth = Math.max(maxWidth, 21);
|
|
109
|
+
// Only request render if still not disposed after potential async work
|
|
110
|
+
if (!this.isDisposed) {
|
|
111
|
+
this.requestRender();
|
|
108
112
|
}
|
|
113
|
+
}
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Use 46 as it fits both cases with proper padding
|
|
115
|
-
maxWidth = Math.max(maxWidth, 46);
|
|
116
|
-
|
|
117
|
-
return Math.min(maxWidth, MAX_WIDTH);
|
|
115
|
+
private calculateWidth(_summary: UsageSummary | null): number {
|
|
116
|
+
// Use fixed table width for consistent layout across all views
|
|
117
|
+
const innerWidth = UsageOverlayComponent.TABLE_INNER_WIDTH;
|
|
118
|
+
return Math.max(MIN_WIDTH, innerWidth + 4) + 6; // +4 for borders, +6 for visual padding (3 on each side)
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
private buildLines(
|
|
@@ -127,7 +128,9 @@ export class UsageOverlayComponent {
|
|
|
127
128
|
|
|
128
129
|
if (error) {
|
|
129
130
|
lines.push(boxTop(this.width));
|
|
130
|
-
lines.push(
|
|
131
|
+
lines.push(
|
|
132
|
+
row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
|
|
133
|
+
);
|
|
131
134
|
lines.push(emptyRow(this.width));
|
|
132
135
|
lines.push(row(th.fg('error', error), this.width));
|
|
133
136
|
if (cachedMinutesAgo !== null) {
|
|
@@ -142,7 +145,9 @@ export class UsageOverlayComponent {
|
|
|
142
145
|
|
|
143
146
|
if (!summary) {
|
|
144
147
|
lines.push(boxTop(this.width));
|
|
145
|
-
lines.push(
|
|
148
|
+
lines.push(
|
|
149
|
+
row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
|
|
150
|
+
);
|
|
146
151
|
lines.push(emptyRow(this.width));
|
|
147
152
|
lines.push(row(th.fg('dim', 'No usage data available.'), this.width));
|
|
148
153
|
lines.push(boxBottom(this.width));
|
|
@@ -152,183 +157,305 @@ export class UsageOverlayComponent {
|
|
|
152
157
|
|
|
153
158
|
// Summary view (subcommand views TODO)
|
|
154
159
|
lines.push(boxTop(this.width));
|
|
155
|
-
lines.push(
|
|
160
|
+
lines.push(
|
|
161
|
+
row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
|
|
162
|
+
);
|
|
156
163
|
lines.push(emptyRow(this.width));
|
|
157
164
|
|
|
158
165
|
// Month row: amount stays with label, cap percentage right-aligned
|
|
159
|
-
const monthLeftBase = `Month $${fmt(summary.month)} / $${fmt(summary.cap)}`;
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
const monthLeftBase = ` Month $${fmt(summary.month)} / $${fmt(summary.cap)}`;
|
|
167
|
+
const monthPercent = summary.cap > 0 ? Math.round((summary.month / summary.cap) * 100) : 0;
|
|
168
|
+
const monthRightText = `cap (${monthPercent}%)`;
|
|
169
|
+
const monthColor = monthPercent < 60 ? 'success' : monthPercent < 100 ? 'warning' : 'error';
|
|
170
|
+
const monthRight =
|
|
171
|
+
monthPercent >= 100
|
|
172
|
+
? th.bold(th.fg('error', monthRightText))
|
|
173
|
+
: th.fg(monthColor, monthRightText);
|
|
174
|
+
lines.push(rowRightAligned(monthLeftBase, monthRight + ' ', this.width));
|
|
175
|
+
|
|
176
|
+
// 7d row: amount stays with label, burn rate right-aligned with color coding
|
|
177
|
+
const weekLeftBase = ` 7d $${fmt(summary.week)}`;
|
|
178
|
+
const burnRatio = summary.cap > 0 ? summary.burnRate / summary.cap : 0;
|
|
179
|
+
let weekRight: string;
|
|
180
|
+
if (burnRatio < 0.9) {
|
|
181
|
+
weekRight = th.fg('success', `burn ~$${fmt(summary.burnRate)}`);
|
|
182
|
+
} else if (burnRatio < 1.5) {
|
|
183
|
+
weekRight = th.fg('warning', `burn ~$${fmt(summary.burnRate)}`);
|
|
184
|
+
} else if (burnRatio < 2.0) {
|
|
185
|
+
weekRight = th.fg('error', `burn ~$${fmt(summary.burnRate)}`);
|
|
186
|
+
} else {
|
|
187
|
+
weekRight = th.bold(th.fg('error', `burn ~$${fmt(summary.burnRate)}`));
|
|
188
|
+
}
|
|
189
|
+
lines.push(rowRightAligned(weekLeftBase, weekRight + ' ', this.width));
|
|
167
190
|
|
|
168
191
|
// Today row on its own line
|
|
169
|
-
const todayContent = `Today $${fmt(summary.today)}`;
|
|
170
|
-
lines.push(rowRightAligned(todayContent, '', this.width));
|
|
192
|
+
const todayContent = ` Today $${fmt(summary.today)}`;
|
|
193
|
+
lines.push(rowRightAligned(todayContent, ' ', this.width));
|
|
171
194
|
lines.push(emptyRow(this.width));
|
|
172
195
|
|
|
173
|
-
// Top models
|
|
174
|
-
if (summary.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
];
|
|
180
|
-
const maxModelNameLen = allModelNames.reduce((max, name) => Math.max(max, name.length), 0);
|
|
181
|
-
const headerModelWidth = Math.max(7, maxModelNameLen);
|
|
182
|
-
const amountWidth = 8; // "$X.XX" + padding
|
|
183
|
-
|
|
184
|
-
lines.push(row('Top models', this.width));
|
|
185
|
-
lines.push(
|
|
186
|
-
row(
|
|
187
|
-
` Model${' '.repeat(headerModelWidth - 5)} ${'7d'.padStart(amountWidth)} ${'30d'.padStart(amountWidth)}`,
|
|
188
|
-
this.width,
|
|
189
|
-
),
|
|
190
|
-
);
|
|
191
|
-
lines.push(
|
|
192
|
-
row(
|
|
193
|
-
` ${'-'.repeat(headerModelWidth)} ${'-'.repeat(amountWidth)} ${'-'.repeat(amountWidth)}`,
|
|
194
|
-
this.width,
|
|
195
|
-
),
|
|
196
|
-
);
|
|
196
|
+
// Top models (7d table)
|
|
197
|
+
if (summary.topModels.length > 0) {
|
|
198
|
+
lines.push(...this.buildModelTableHeader('7d', th));
|
|
199
|
+
lines.push(...this.buildModelTableRows(summary.topModels, '7d'));
|
|
200
|
+
lines.push(emptyRow(this.width));
|
|
201
|
+
}
|
|
197
202
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
for (const m of summary.topModels30d) {
|
|
205
|
-
const existing = spendMap.get(m.name);
|
|
206
|
-
spendMap.set(m.name, {
|
|
207
|
-
spend7d: existing?.spend7d ?? 0,
|
|
208
|
-
spend30d: m.spend,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
203
|
+
// Top models (30d table)
|
|
204
|
+
if (summary.topModels.length > 0) {
|
|
205
|
+
lines.push(...this.buildModelTableHeader('30d', th));
|
|
206
|
+
lines.push(...this.buildModelTableRows(summary.topModels, '30d'));
|
|
207
|
+
lines.push(emptyRow(this.width));
|
|
208
|
+
}
|
|
211
209
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
210
|
+
// By provider (30d table with tokens)
|
|
211
|
+
if (summary.byProvider.length > 0) {
|
|
212
|
+
lines.push(...this.buildProviderTable(summary.byProvider, th));
|
|
213
|
+
lines.push(emptyRow(this.width));
|
|
214
|
+
}
|
|
216
215
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
this.width,
|
|
225
|
-
),
|
|
226
|
-
);
|
|
216
|
+
// Usage by Day (30d bar chart)
|
|
217
|
+
if (summary.byDay && Object.keys(summary.byDay).length > 0) {
|
|
218
|
+
const chartOutput = renderSpendBarChart(summary.byDay, this.width);
|
|
219
|
+
lines.push(row(` Daily spend (30d)`, this.width));
|
|
220
|
+
// Split multi-line chart output and add each line
|
|
221
|
+
for (const chartLine of chartOutput.split('\n')) {
|
|
222
|
+
lines.push(row(chartLine, this.width));
|
|
227
223
|
}
|
|
228
224
|
lines.push(emptyRow(this.width));
|
|
229
225
|
}
|
|
230
226
|
|
|
231
|
-
//
|
|
232
|
-
if (summary
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
lines.push(row('By provider', this.width));
|
|
239
|
-
lines.push(row(` Provider${' '.repeat(maxProviderLen - 8)} 30d`, this.width));
|
|
240
|
-
lines.push(row(` ${'-'.repeat(maxProviderLen)} ------`, this.width));
|
|
227
|
+
// Last refresh time at the bottom
|
|
228
|
+
if (summary?.timestamp) {
|
|
229
|
+
const refreshDate = new Date(summary.timestamp);
|
|
230
|
+
const timestampStr = refreshDate.toLocaleTimeString();
|
|
231
|
+
lines.push(row(` Last refreshed: ${timestampStr}`, this.width));
|
|
232
|
+
lines.push(emptyRow(this.width));
|
|
241
233
|
|
|
242
|
-
|
|
234
|
+
// Warning if data is limited due to missing management key
|
|
235
|
+
if (!summary.hasActivityData) {
|
|
243
236
|
lines.push(
|
|
244
237
|
row(
|
|
245
|
-
|
|
238
|
+
th.fg('warning', ' Data limited - use management key for model breakdowns'),
|
|
246
239
|
this.width,
|
|
247
240
|
),
|
|
248
241
|
);
|
|
242
|
+
lines.push(emptyRow(this.width));
|
|
249
243
|
}
|
|
250
|
-
lines.push(emptyRow(this.width));
|
|
251
244
|
}
|
|
252
245
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.slice(-7); // Last 7 days
|
|
258
|
-
const maxDateLen = sortedDays.reduce((max, [date]) => Math.max(max, date.length), 0);
|
|
246
|
+
lines.push(boxBottom(this.width));
|
|
247
|
+
lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
|
|
248
|
+
return lines;
|
|
249
|
+
}
|
|
259
250
|
|
|
260
|
-
|
|
261
|
-
lines.push(row(` Date${' '.repeat(maxDateLen - 4)} Amount`, this.width));
|
|
262
|
-
lines.push(row(` ${'-'.repeat(maxDateLen)} ------`, this.width));
|
|
251
|
+
// Formatting utilities
|
|
263
252
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
253
|
+
private fmtCostPerM(spend: number, tokens: number): string {
|
|
254
|
+
if (tokens === 0) return '-';
|
|
255
|
+
return `$${((spend / tokens) * 1_000_000).toFixed(2)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Shared table row builder
|
|
259
|
+
private buildTableRow<T>(data: T[], renderRow: (item: T) => string, theme?: Theme): string[] {
|
|
260
|
+
const { COLS } = UsageOverlayComponent;
|
|
261
|
+
const lines: string[] = [];
|
|
262
|
+
|
|
263
|
+
if (theme) {
|
|
264
|
+
lines.push(
|
|
265
|
+
row(
|
|
266
|
+
` ${theme.fg('dim', '-'.repeat(COLS.model))} ${theme.fg('dim', '-'.repeat(COLS.spend))} ` +
|
|
267
|
+
`${theme.fg('dim', '-'.repeat(COLS.tokens))} ${theme.fg('dim', '-'.repeat(COLS.costPerM))} ` +
|
|
268
|
+
`${theme.fg('dim', '-'.repeat(COLS.reqs))}`,
|
|
269
|
+
this.width,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
270
272
|
}
|
|
271
273
|
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const timestampStr = refreshDate.toLocaleTimeString();
|
|
276
|
-
lines.push(row(`Last refreshed: ${timestampStr}`, this.width));
|
|
277
|
-
lines.push(emptyRow(this.width));
|
|
274
|
+
// Data rows
|
|
275
|
+
for (const item of data) {
|
|
276
|
+
lines.push(row(renderRow(item), this.width));
|
|
278
277
|
}
|
|
279
278
|
|
|
280
|
-
lines.push(boxBottom(this.width));
|
|
281
|
-
lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
|
|
282
279
|
return lines;
|
|
283
280
|
}
|
|
281
|
+
|
|
282
|
+
// Model table header builder
|
|
283
|
+
private buildModelTableHeader(label: '7d' | '30d', theme: Theme): string[] {
|
|
284
|
+
const { COLS } = UsageOverlayComponent;
|
|
285
|
+
const lines: string[] = [];
|
|
286
|
+
|
|
287
|
+
lines.push(row(` Top models (${label})`, this.width));
|
|
288
|
+
lines.push(
|
|
289
|
+
row(
|
|
290
|
+
` ${'Model'.padEnd(COLS.model)} ${`${label} $`.padStart(COLS.spend)} ` +
|
|
291
|
+
`${`${label} tok`.padStart(COLS.tokens)} ${'$/M'.padStart(COLS.costPerM)} ` +
|
|
292
|
+
`${'reqs'.padStart(COLS.reqs)}`,
|
|
293
|
+
this.width,
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
lines.push(
|
|
297
|
+
row(
|
|
298
|
+
` ${theme.fg('dim', '-'.repeat(COLS.model))} ${theme.fg('dim', '-'.repeat(COLS.spend))} ` +
|
|
299
|
+
`${theme.fg('dim', '-'.repeat(COLS.tokens))} ${theme.fg('dim', '-'.repeat(COLS.costPerM))} ` +
|
|
300
|
+
`${theme.fg('dim', '-'.repeat(COLS.reqs))}`,
|
|
301
|
+
this.width,
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
return lines;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Model table row builder
|
|
309
|
+
private buildModelTableRows(models: ModelStats[], period: '7d' | '30d'): string[] {
|
|
310
|
+
const { COLS } = UsageOverlayComponent;
|
|
311
|
+
const is7d = period === '7d';
|
|
312
|
+
|
|
313
|
+
// Filter to models with spend in this period, sort by spend desc, limit to 4
|
|
314
|
+
const sorted = models
|
|
315
|
+
.filter((m) => (is7d ? m.spend7d > 0 : m.spend30d > 0))
|
|
316
|
+
.sort((a, b) => (is7d ? b.spend7d - a.spend7d : b.spend30d - a.spend30d))
|
|
317
|
+
.slice(0, 4);
|
|
318
|
+
|
|
319
|
+
const renderRow = (m: ModelStats) => {
|
|
320
|
+
const spend = is7d ? m.spend7d : m.spend30d;
|
|
321
|
+
const tokens = is7d ? m.tokens7d.total : m.tokens30d.total;
|
|
322
|
+
const reqs = is7d ? m.requests7d : m.requests30d;
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
` ${truncate(m.name, COLS.model).padEnd(COLS.model)} ` +
|
|
326
|
+
`${`$${fmt(spend)}`.padStart(COLS.spend)} ` +
|
|
327
|
+
`${fmtTokens(tokens).padStart(COLS.tokens)} ` +
|
|
328
|
+
`${this.fmtCostPerM(spend, tokens).padStart(COLS.costPerM)} ` +
|
|
329
|
+
`${fmtCount(reqs).padStart(COLS.reqs)}`
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return this.buildTableRow(sorted, renderRow);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Provider table builder
|
|
337
|
+
private buildProviderTable(providers: ProviderStats[], theme: Theme): string[] {
|
|
338
|
+
const { COLS } = UsageOverlayComponent;
|
|
339
|
+
const lines: string[] = [];
|
|
340
|
+
|
|
341
|
+
// Header
|
|
342
|
+
lines.push(row(` By provider (30d)`, this.width));
|
|
343
|
+
lines.push(
|
|
344
|
+
row(
|
|
345
|
+
` ${'Provider'.padEnd(COLS.model)} ${'$'.padStart(COLS.spend)} ` +
|
|
346
|
+
`${'tok'.padStart(COLS.tokens)} ${'$/M'.padStart(COLS.costPerM)} ` +
|
|
347
|
+
`${'reqs'.padStart(COLS.reqs)}`,
|
|
348
|
+
this.width,
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Data rows - top 4 providers
|
|
353
|
+
const sorted = providers.filter((p) => p.spend > 0).slice(0, 4);
|
|
354
|
+
|
|
355
|
+
const renderRow = (p: ProviderStats) => {
|
|
356
|
+
return (
|
|
357
|
+
` ${truncate(p.name, COLS.model).padEnd(COLS.model)} ` +
|
|
358
|
+
`${`$${fmt(p.spend)}`.padStart(COLS.spend)} ` +
|
|
359
|
+
`${fmtTokens(p.tokens.total).padStart(COLS.tokens)} ` +
|
|
360
|
+
`${this.fmtCostPerM(p.spend, p.tokens.total).padStart(COLS.costPerM)} ` +
|
|
361
|
+
`${fmtCount(p.requests).padStart(COLS.reqs)}`
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
return [...lines, ...this.buildTableRow(sorted, renderRow, theme)];
|
|
366
|
+
}
|
|
284
367
|
}
|
|
285
368
|
|
|
286
369
|
// Helper functions
|
|
287
370
|
function boxTop(width: number): string {
|
|
288
|
-
return
|
|
371
|
+
return `┌─${'─'.repeat(width - 4)}─┐`;
|
|
289
372
|
}
|
|
290
373
|
|
|
291
374
|
function boxBottom(width: number): string {
|
|
292
|
-
return
|
|
375
|
+
return `└─${'─'.repeat(width - 4)}─┘`;
|
|
293
376
|
}
|
|
294
377
|
|
|
295
378
|
function emptyRow(width: number): string {
|
|
296
|
-
return
|
|
379
|
+
return `│ ${' '.repeat(width - 4)} │`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Truncate string to visible width, skipping ANSI escape codes
|
|
383
|
+
function truncateToVisibleWidth(str: string, maxVisibleWidth: number): string {
|
|
384
|
+
let visibleSoFar = 0;
|
|
385
|
+
let i = 0;
|
|
386
|
+
|
|
387
|
+
while (i < str.length && visibleSoFar < maxVisibleWidth) {
|
|
388
|
+
const char = str[i];
|
|
389
|
+
|
|
390
|
+
if (char === '\x1b') {
|
|
391
|
+
// Skip ANSI escape sequence
|
|
392
|
+
// eslint-disable-next-line no-control-regex
|
|
393
|
+
const ansiMatch = str.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
394
|
+
if (ansiMatch) {
|
|
395
|
+
i += ansiMatch[0].length;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
visibleSoFar++;
|
|
400
|
+
i++;
|
|
401
|
+
}
|
|
402
|
+
return str.slice(0, i);
|
|
297
403
|
}
|
|
298
404
|
|
|
299
405
|
function row(content: string, width: number): string {
|
|
300
|
-
const
|
|
301
|
-
|
|
406
|
+
const innerWidth = width - 4; // -4 for box borders + padding spaces
|
|
407
|
+
const truncated = truncateToVisibleWidth(content, innerWidth);
|
|
408
|
+
return `│ ${truncated}${' '.repeat(innerWidth - getVisibleWidth(truncated))} │`;
|
|
302
409
|
}
|
|
303
410
|
|
|
304
411
|
function plainRow(content: string, width: number): string {
|
|
305
|
-
const
|
|
306
|
-
|
|
412
|
+
const innerWidth = width - 2; // -2 for outer spaces
|
|
413
|
+
const truncated = truncateToVisibleWidth(content, innerWidth);
|
|
414
|
+
return ` ${truncated}${' '.repeat(innerWidth - getVisibleWidth(truncated))} `;
|
|
307
415
|
}
|
|
308
416
|
|
|
309
417
|
// Helper to create a row with left content padded to align right content
|
|
310
418
|
function rowRightAligned(leftContent: string, rightContent: string, width: number): string {
|
|
311
|
-
const
|
|
312
|
-
const
|
|
419
|
+
const innerWidth = width - 4; // -4 for box borders + padding spaces
|
|
420
|
+
const rightVisibleWidth = getVisibleWidth(rightContent);
|
|
313
421
|
|
|
314
|
-
if (
|
|
422
|
+
if (rightVisibleWidth === 0) {
|
|
315
423
|
// No right content - just pad left to full width
|
|
316
|
-
const leftPadded = leftContent.padEnd(
|
|
424
|
+
const leftPadded = leftContent.padEnd(innerWidth, ' ');
|
|
317
425
|
return row(leftPadded, width);
|
|
318
426
|
}
|
|
319
427
|
|
|
320
428
|
// Account for the space between left and right content
|
|
321
|
-
const remainingWidth =
|
|
429
|
+
const remainingWidth = innerWidth - rightVisibleWidth - 1;
|
|
430
|
+
|
|
431
|
+
// Get visible version of left content for truncation check
|
|
432
|
+
const leftVisible = getVisibleWidth(leftContent);
|
|
322
433
|
|
|
323
434
|
// Pad left content to align right content
|
|
324
435
|
const leftPadded =
|
|
325
|
-
|
|
436
|
+
leftVisible > remainingWidth
|
|
326
437
|
? leftContent.slice(0, remainingWidth - 3) + '...'
|
|
327
438
|
: leftContent.padEnd(remainingWidth, ' ');
|
|
328
439
|
|
|
329
440
|
return row(`${leftPadded} ${rightContent}`, width);
|
|
330
441
|
}
|
|
331
442
|
|
|
443
|
+
// Calculate visible width of a string, excluding ANSI escape codes
|
|
444
|
+
function getVisibleWidth(str: string): number {
|
|
445
|
+
// Remove ANSI escape codes - handles CSI sequences (ESC [ ... m)
|
|
446
|
+
// eslint-disable-next-line no-control-regex
|
|
447
|
+
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
|
448
|
+
const cleanStr = str.replace(ansiRegex, '');
|
|
449
|
+
return cleanStr.length;
|
|
450
|
+
}
|
|
451
|
+
|
|
332
452
|
function fmt(value: number): string {
|
|
333
453
|
return value.toFixed(2);
|
|
334
454
|
}
|
|
455
|
+
|
|
456
|
+
// Truncate string to max length, adding ellipsis if needed
|
|
457
|
+
function truncate(str: string, maxLen: number): string {
|
|
458
|
+
if (str.length <= maxLen) return str;
|
|
459
|
+
if (maxLen <= 3) return str.slice(0, maxLen);
|
|
460
|
+
return str.slice(0, maxLen - 3) + '...';
|
|
461
|
+
}
|
|
@@ -1,15 +1,38 @@
|
|
|
1
|
+
export interface TokenStats {
|
|
2
|
+
input: number;
|
|
3
|
+
output: number;
|
|
4
|
+
reasoning: number;
|
|
5
|
+
total: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ModelStats {
|
|
9
|
+
name: string;
|
|
10
|
+
spend7d: number;
|
|
11
|
+
spend30d: number;
|
|
12
|
+
tokens7d: TokenStats;
|
|
13
|
+
tokens30d: TokenStats;
|
|
14
|
+
requests7d: number;
|
|
15
|
+
requests30d: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProviderStats {
|
|
19
|
+
name: string;
|
|
20
|
+
spend: number;
|
|
21
|
+
tokens: TokenStats;
|
|
22
|
+
requests: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
1
25
|
export interface UsageSummary {
|
|
2
26
|
today: number;
|
|
3
27
|
week: number;
|
|
4
28
|
month: number;
|
|
5
29
|
cap: number;
|
|
6
30
|
burnRate: number;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
byKey?: Record<string, number>;
|
|
11
|
-
byDay?: Record<string, number>;
|
|
31
|
+
topModels: ModelStats[];
|
|
32
|
+
byProvider: ProviderStats[];
|
|
33
|
+
byDay: Record<string, number>;
|
|
12
34
|
timestamp: number;
|
|
35
|
+
hasActivityData: boolean;
|
|
13
36
|
}
|
|
14
37
|
|
|
15
38
|
export interface CacheEntry<T> {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robhowley/pi-openrouter",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terminal overlay for OpenRouter usage: caps, spend, burn rate, and model breakdowns.",
|
|
6
6
|
"files": [
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"pi": {
|
|
19
19
|
"extensions": [
|
|
20
20
|
"./extensions/openrouter"
|
|
21
|
-
]
|
|
21
|
+
],
|
|
22
|
+
"image": "https://raw.githubusercontent.com/robhowley/pi-userland/main/packages/pi-openrouter/img/openrouter-usage-tui.png"
|
|
22
23
|
},
|
|
23
24
|
"repository": {
|
|
24
25
|
"type": "git",
|