@robhowley/pi-openrouter 0.5.0 → 0.6.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-openrouter
2
2
 
3
- A [Pi](https://pi.dev/) extension for OpenRouter usage and session visibility, with an `/openrouter-usage` terminal overlay for spend, caps, burn rate, live tracking, and model breakdowns, plus automatic `session_id` tagging for dashboard grouping.
3
+ A [Pi](https://pi.dev/) extension for live OpenRouter visibility: TUI overlays for spend, credits, key limits, burn rate, and model usage, plus automatic `session_id` tagging for dashboard grouping.
4
4
 
5
5
  ## Installation
6
6
 
@@ -35,6 +35,25 @@ The extension refreshes data in the background every 30 seconds (with exponentia
35
35
 
36
36
  <img src="https://raw.githubusercontent.com/robhowley/pi-userland/main/packages/pi-openrouter/img/openrouter-usage-tui.png" alt="OpenRouter Usage Overlay" width="600">
37
37
 
38
+ ## Account health
39
+
40
+ Type `/openrouter-account` in Pi to open the account health overlay.
41
+
42
+ The overlay shows:
43
+
44
+ - **Credits** balance
45
+ - **Total usage** against available credits
46
+ - **Status by key**
47
+ - **Selected key** details
48
+ - **Key spend** vs configured limit
49
+ - **Reset cadence**
50
+ - **BYOK limit behavior**
51
+ - **All visible keys**, when a management key is configured
52
+
53
+ Select a key from the list to inspect its limit, usage, reset cadence, and BYOK behavior.
54
+
55
+ <img src="https://raw.githubusercontent.com/robhowley/pi-userland/main/packages/pi-openrouter/img/openrouter-account-tui.png" alt="OpenRouter Account Overlay" width="600">
56
+
38
57
  ## Session tracking
39
58
 
40
59
  `pi-openrouter` automatically tags OpenRouter requests with `session_id` field set to the Pi session's ID.
@@ -0,0 +1,215 @@
1
+ import { OpenRouter } from '@openrouter/sdk/sdk/sdk.js';
2
+ import type { KeyInfo, KeyStatus } from './account-types.js';
3
+
4
+ // Re-export error types from client.ts
5
+ import { AuthError, ApiError } from './client.js';
6
+
7
+ let client: OpenRouter | null = null;
8
+
9
+ function getClient(): OpenRouter | null {
10
+ if (client) return client;
11
+ const apiKey = process.env['OPENROUTER_MANAGEMENT_KEY'] || process.env['OPENROUTER_API_KEY'];
12
+ if (!apiKey) return null;
13
+ client = new OpenRouter({ apiKey });
14
+ return client;
15
+ }
16
+
17
+ // =============================================================================
18
+ // Account Credits API
19
+ // =============================================================================
20
+
21
+ export async function getAccountCredits(): Promise<number | null> {
22
+ const client = getClient();
23
+ if (!client) return null;
24
+ try {
25
+ const response = await client.credits.getCredits();
26
+ return response.data.totalCredits ?? null;
27
+ } catch (err) {
28
+ throw mapSdkError(err);
29
+ }
30
+ }
31
+
32
+ // =============================================================================
33
+ // Key Management API
34
+ // =============================================================================
35
+
36
+ import type { GetCurrentKeyData, ListData } from '@openrouter/sdk/models/operations/index.js';
37
+
38
+ // Workspace ID for the default workspace (empty string) - used when workspaceId is not specified
39
+ const DEFAULT_WORKSPACE_ID = '';
40
+
41
+ export async function getAllKeys(): Promise<KeyInfo[] | null> {
42
+ const client = getClient();
43
+ if (!client) return null;
44
+
45
+ try {
46
+ // First, get all workspaces
47
+ const workspacesResponse = await client.workspaces.list();
48
+ const workspaces: Array<{ id: string; name: string }> = [];
49
+ for await (const response of workspacesResponse) {
50
+ // response.result contains the ListWorkspacesResponse with data array
51
+ for (const workspace of response.result.data) {
52
+ workspaces.push({ id: workspace.id, name: workspace.name });
53
+ }
54
+ }
55
+
56
+ // Fetch keys from each workspace and combine them
57
+ const allKeys: KeyInfo[] = [];
58
+
59
+ for (const workspace of workspaces) {
60
+ const workspaceId = workspace.id || DEFAULT_WORKSPACE_ID;
61
+ const response = await client.apiKeys.list({ workspaceId, includeDisabled: true });
62
+ const rawKeys = response.data;
63
+
64
+ const keys = rawKeys.map((raw) => rawToKeyInfo(raw, workspace.name));
65
+ allKeys.push(...keys);
66
+ }
67
+
68
+ return allKeys;
69
+ } catch (err) {
70
+ // If management key fails (403), fall back to current key only
71
+ const sdkErr = err as { status?: number };
72
+ if (sdkErr.status === 403) {
73
+ return null;
74
+ }
75
+ throw mapSdkError(err);
76
+ }
77
+ }
78
+
79
+ export async function getCurrentKey(): Promise<KeyInfo | null> {
80
+ const client = getClient();
81
+ if (!client) return null;
82
+ try {
83
+ const response = await client.apiKeys.getCurrentKeyMetadata();
84
+ return rawToKeyInfo(response.data, 'Current Workspace');
85
+ } catch (err) {
86
+ throw mapSdkError(err);
87
+ }
88
+ }
89
+
90
+ // =============================================================================
91
+ // Helper Functions
92
+ // =============================================================================
93
+
94
+ function rawToKeyInfo(raw: GetCurrentKeyData | ListData, workspaceName: string): KeyInfo {
95
+ const used = raw.usage ?? raw.usageMonthly ?? 0;
96
+
97
+ // limit is number | null in both GetCurrentKeyData and ListData
98
+ const limitValue = raw.limit;
99
+
100
+ // remaining is number | null in both types
101
+ const remainingValue = raw.limitRemaining;
102
+
103
+ // Determine BYOK status
104
+ let byok: 'incl' | 'excl' | '?' = '?';
105
+ if (raw.includeByokInLimit === true) {
106
+ byok = 'incl';
107
+ } else if (raw.includeByokInLimit === false) {
108
+ byok = 'excl';
109
+ }
110
+
111
+ // Determine reset cadence
112
+ let resetCadence: 'monthly' | 'daily' | 'never' | 'partial' = 'partial';
113
+ if (raw.limitReset) {
114
+ const reset = raw.limitReset.toLowerCase();
115
+ if (reset === 'monthly') {
116
+ resetCadence = 'monthly';
117
+ } else if (reset === 'daily') {
118
+ resetCadence = 'daily';
119
+ } else if (reset === 'never') {
120
+ resetCadence = 'never';
121
+ }
122
+ }
123
+
124
+ // Determine hash (ListData has hash, GetCurrentKeyData doesn't)
125
+ const hash = 'hash' in raw ? (raw as ListData).hash : 'unknown';
126
+
127
+ // Determine name (ListData has name, GetCurrentKeyData doesn't - use label as fallback)
128
+ const name = 'name' in raw ? (raw as ListData).name : raw.label;
129
+
130
+ // Get disabled status (ListData has it, GetCurrentKeyData doesn't)
131
+ const disabled = 'disabled' in raw ? (raw as ListData).disabled : false;
132
+
133
+ // For exactOptionalPropertyTypes, we need to handle optional properties carefully
134
+ // The SDK returns number | null but KeyInfo expects number | undefined (or just number)
135
+ // We use type assertion to tell TypeScript that limit/remaining are either number or not set
136
+
137
+ // When limitValue is null, we set limit to undefined (or omit it)
138
+ // When limitValue is a number, we keep it as is
139
+ let limit: number | undefined;
140
+ if (limitValue !== null) {
141
+ limit = limitValue;
142
+ }
143
+
144
+ let remaining: number | undefined;
145
+ if (remainingValue !== null) {
146
+ remaining = remainingValue;
147
+ }
148
+
149
+ // Calculate status based on usage percentage
150
+ let status: KeyStatus;
151
+ if (disabled) {
152
+ status = 'disabled';
153
+ } else if (limit === undefined || limit === null) {
154
+ status = 'unbounded';
155
+ } else if (remaining !== undefined && remaining < 0) {
156
+ status = 'danger';
157
+ } else if (limit === 0) {
158
+ status = 'danger';
159
+ } else {
160
+ const usedPercent = (used / limit) * 100;
161
+ if (usedPercent >= 90) {
162
+ status = 'danger';
163
+ } else if (usedPercent >= 70) {
164
+ status = 'caution';
165
+ } else {
166
+ status = 'healthy';
167
+ }
168
+ }
169
+
170
+ // Create the object
171
+ const keyInfo: KeyInfo = {
172
+ name,
173
+ label: raw.label,
174
+ status,
175
+ used,
176
+ spend: used, // spend is the same as usage (in USD)
177
+ resetCadence,
178
+ byok,
179
+ hash,
180
+ disabled,
181
+ workspaceName,
182
+ };
183
+
184
+ // Set optional properties explicitly
185
+ if (limit !== undefined) {
186
+ keyInfo.limit = limit;
187
+ }
188
+ if (remaining !== undefined) {
189
+ keyInfo.remaining = remaining;
190
+ }
191
+
192
+ return keyInfo;
193
+ }
194
+
195
+ function mapSdkError(err: unknown): Error {
196
+ const rawErr = err as { status?: number; message?: string };
197
+ const status = rawErr.status;
198
+ const message = rawErr.message ?? 'Unknown error';
199
+
200
+ if (status === 401) {
201
+ return new AuthError(message);
202
+ }
203
+ if (status === 403) {
204
+ return new ApiError(`Forbidden: ${message}`);
205
+ }
206
+
207
+ if (err instanceof Error) return err;
208
+ return new Error(String(err));
209
+ }
210
+
211
+ export function getCurrentKeyHash(): string | undefined {
212
+ // For v1, we don't hash the current API key for comparison
213
+ // This is a follow-up item from the planning docs
214
+ return undefined;
215
+ }
@@ -0,0 +1,97 @@
1
+ import type { KeyInfo, KeyStatus, RollupStatus } from './account-types.js';
2
+
3
+ // =============================================================================
4
+ // Status Computation
5
+ // =============================================================================
6
+
7
+ /** Compute KeyStatus based on usage/limit ratio */
8
+ export function computeKeyStatus(used: number, limit?: number, disabled?: boolean): KeyStatus {
9
+ // Disabled keys always show disabled
10
+ if (disabled) return 'disabled';
11
+
12
+ // No limit means unbounded
13
+ if (limit === undefined) return 'unbounded';
14
+
15
+ const usageRatio = used / limit;
16
+
17
+ if (usageRatio < 0.7) {
18
+ return 'healthy';
19
+ } else if (usageRatio < 0.9) {
20
+ return 'caution';
21
+ } else {
22
+ return 'danger';
23
+ }
24
+ }
25
+
26
+ // =============================================================================
27
+ // Formatting
28
+ // =============================================================================
29
+
30
+ /** Format currency amount */
31
+ export function formatCurrency(amount: number): string {
32
+ return `$${amount.toFixed(2)}`;
33
+ }
34
+
35
+ /** Format remaining with optional limit */
36
+ export function formatRemaining(used: number, limit?: number): string {
37
+ if (limit === undefined) {
38
+ return `${formatCurrency(used)} / unlimited`;
39
+ }
40
+ return `${formatCurrency(used)} / ${formatCurrency(limit)}`;
41
+ }
42
+
43
+ /** Format remaining text */
44
+ export function formatLeft(remaining?: number): string {
45
+ if (remaining === undefined) return '-';
46
+ return formatCurrency(remaining);
47
+ }
48
+
49
+ // =============================================================================
50
+ // Sorting
51
+ // =============================================================================
52
+
53
+ /** Sort keys by priority: active first, then spend desc, then usage % desc */
54
+ export function sortKeys(keys: KeyInfo[]): KeyInfo[] {
55
+ return [...keys].sort((a, b) => {
56
+ // Active keys first (disabled = false before disabled = true)
57
+ if (a.disabled !== b.disabled) {
58
+ return a.disabled ? 1 : -1;
59
+ }
60
+
61
+ // Within active group: spend descending
62
+ if (a.spend !== b.spend) {
63
+ return b.spend - a.spend;
64
+ }
65
+
66
+ // Within active group with same spend: usage % descending
67
+ const usagePercentA = a.limit ? (a.used / a.limit) * 100 : 0;
68
+ const usagePercentB = b.limit ? (b.used / b.limit) * 100 : 0;
69
+ if (usagePercentA !== usagePercentB) {
70
+ return usagePercentB - usagePercentA;
71
+ }
72
+
73
+ // Alphabetically by name as tiebreaker
74
+ return a.name.localeCompare(b.name);
75
+ });
76
+ }
77
+
78
+ // =============================================================================
79
+ // Rollup Status
80
+ // =============================================================================
81
+
82
+ /** Compute overall account status from individual key statuses */
83
+ export function computeRollupStatus(keys: KeyInfo[]): RollupStatus {
84
+ if (keys.length === 0) {
85
+ return { status: 'unavailable' as const };
86
+ }
87
+
88
+ // Count keys by traffic light status
89
+ const red = keys.filter((k) => k.status === 'disabled' || k.status === 'danger').length;
90
+ const yellow = keys.filter((k) => k.status === 'caution').length;
91
+ const green = keys.filter((k) => k.status === 'healthy' || k.status === 'unbounded').length;
92
+
93
+ return {
94
+ status: 'healthy' as const,
95
+ message: `🔴 ${red} 🟡 ${yellow} 🟢 ${green}`,
96
+ };
97
+ }
@@ -0,0 +1,440 @@
1
+ import { matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
2
+ import type { Theme, ThemeColor } from '@mariozechner/pi-coding-agent';
3
+ import type { KeyInfo, KeyStatus, RollupStatus } from './account-types.js';
4
+ import {
5
+ computeRollupStatus,
6
+ formatCurrency,
7
+ formatRemaining,
8
+ sortKeys,
9
+ } from './account-format.js';
10
+ import { getAllKeys, getCurrentKey, getAccountCredits } from './account-client.js';
11
+ import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
12
+
13
+ // =============================================================================
14
+ // Constants
15
+ // =============================================================================
16
+
17
+ const MIN_WIDTH = 65;
18
+
19
+ // =============================================================================
20
+ // Account Overlay Component
21
+ // =============================================================================
22
+
23
+ export class AccountOverlayComponent {
24
+ private lines: string[];
25
+ private theme: Theme;
26
+ private onClose: () => void;
27
+ private width: number;
28
+ private keyInfo: KeyInfo[] | null;
29
+ private credits: number | null;
30
+ private rollupStatus: RollupStatus;
31
+ private error: string | null;
32
+ private selectedIndex: number;
33
+ private refreshTimer: NodeJS.Timeout | null = null;
34
+ private requestRender: () => void;
35
+ private isDisposed = false;
36
+ private ctx: ExtensionContext | null = null;
37
+
38
+ constructor(
39
+ keyInfo: KeyInfo[] | null,
40
+ credits: number | null,
41
+ rollupStatus: RollupStatus,
42
+ error: string | null,
43
+ theme: Theme,
44
+ onClose: () => void,
45
+ requestRender: () => void,
46
+ ctx?: ExtensionContext,
47
+ ) {
48
+ this.theme = theme;
49
+ this.onClose = onClose;
50
+ this.requestRender = requestRender;
51
+ this.keyInfo = keyInfo;
52
+ this.credits = credits;
53
+ this.rollupStatus = rollupStatus;
54
+ this.error = error;
55
+ this.selectedIndex = 0;
56
+ this.ctx = ctx || null;
57
+ this.width = this.calculateWidth();
58
+ this.lines = this.buildLines();
59
+
60
+ // Set up timer to rebuild lines every 30 seconds
61
+ this.refreshTimer = setInterval(() => {
62
+ this.invalidate();
63
+ }, 30000);
64
+ }
65
+
66
+ dispose(): void {
67
+ this.isDisposed = true;
68
+ if (this.refreshTimer) {
69
+ clearInterval(this.refreshTimer);
70
+ this.refreshTimer = null;
71
+ }
72
+ }
73
+
74
+ handleInput(data: string): void {
75
+ // Close on q, escape, or ctrl+c
76
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || data === 'q') {
77
+ this.onClose();
78
+ return;
79
+ }
80
+
81
+ // Refresh on r
82
+ if (matchesKey(data, 'r')) {
83
+ this.refresh();
84
+ return;
85
+ }
86
+
87
+ // Key selection with arrow keys
88
+ if (this.keyInfo && this.keyInfo.length > 0) {
89
+ if (matchesKey(data, 'up')) {
90
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
91
+ this.invalidate();
92
+ } else if (matchesKey(data, 'down')) {
93
+ this.selectedIndex = Math.min(this.keyInfo.length - 1, this.selectedIndex + 1);
94
+ this.invalidate();
95
+ }
96
+ }
97
+ }
98
+
99
+ wantsKeyRelease = false;
100
+
101
+ render(width: number): string[] {
102
+ // Center the overlay if terminal is wider
103
+ const padding = Math.max(0, Math.floor((width - this.width) / 2));
104
+ const pad = ' '.repeat(padding);
105
+
106
+ return this.lines.map((line) => truncateToWidth(pad + line, width));
107
+ }
108
+
109
+ invalidate(): void {
110
+ if (this.isDisposed) return;
111
+ // Rebuild lines to update "last refreshed" time
112
+ this.lines = this.buildLines();
113
+ // Clamp selected index if key list shrunk after re-sort
114
+ if (this.keyInfo && this.selectedIndex >= this.keyInfo.length) {
115
+ this.selectedIndex = this.keyInfo.length - 1;
116
+ }
117
+ if (!this.isDisposed) {
118
+ this.requestRender();
119
+ }
120
+ }
121
+
122
+ async refresh(): Promise<void> {
123
+ if (this.isDisposed || !this.ctx) return;
124
+
125
+ try {
126
+ const allKeys = await getAllKeys();
127
+ let credits: number | null = null;
128
+ try {
129
+ credits = await getAccountCredits();
130
+ } catch {
131
+ // Silently ignore credit fetch errors
132
+ }
133
+
134
+ let error: string | null = null;
135
+ let keyInfo: KeyInfo[] | null = null;
136
+
137
+ if (allKeys && allKeys.length > 0) {
138
+ keyInfo = allKeys;
139
+ } else {
140
+ error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
141
+ try {
142
+ const currentKey = await getCurrentKey();
143
+ if (currentKey) {
144
+ keyInfo = [currentKey];
145
+ }
146
+ } catch {
147
+ // Ignore secondary errors
148
+ }
149
+ }
150
+
151
+ const rollupStatus = keyInfo
152
+ ? computeRollupStatus(keyInfo)
153
+ : { status: 'unavailable' as const };
154
+
155
+ // Update state
156
+ this.keyInfo = keyInfo;
157
+ this.credits = credits;
158
+ this.rollupStatus = rollupStatus;
159
+ this.error = error;
160
+
161
+ // Reset selection and rebuild
162
+ this.selectedIndex = 0;
163
+ this.width = this.calculateWidth();
164
+ this.lines = this.buildLines();
165
+
166
+ this.requestRender();
167
+ } catch {
168
+ // Silently ignore refresh errors
169
+ }
170
+ }
171
+
172
+ private calculateWidth(): number {
173
+ return Math.max(MIN_WIDTH, this.keyInfo && this.keyInfo.length > 0 ? 55 : 50);
174
+ }
175
+
176
+ private buildLines(): string[] {
177
+ const th = this.theme;
178
+ const lines: string[] = [];
179
+
180
+ if (this.error) {
181
+ lines.push(boxTop(this.width));
182
+ lines.push(
183
+ row(th.fg('accent', th.bold(' ◈ OpenRouter Account · /openrouter-account')), this.width),
184
+ );
185
+ lines.push(emptyRow(this.width));
186
+ lines.push(row(th.fg('error', this.error), this.width));
187
+ lines.push(boxBottom(this.width));
188
+ lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
189
+ return lines;
190
+ }
191
+
192
+ lines.push(boxTop(this.width));
193
+ lines.push(
194
+ row(th.fg('accent', th.bold(' ◈ OpenRouter Account · /openrouter-account')), this.width),
195
+ );
196
+ lines.push(emptyRow(this.width));
197
+
198
+ // Total spend line (sum of all key spends)
199
+ if (this.keyInfo && this.keyInfo.length > 0) {
200
+ const totalSpend = this.keyInfo.reduce((sum, k) => sum + k.spend, 0);
201
+ lines.push(row(` usage ${formatCurrency(totalSpend)}`, this.width));
202
+ }
203
+
204
+ // Credits line
205
+ if (this.credits !== null) {
206
+ lines.push(row(` credits ${formatCurrency(this.credits)}`, this.width));
207
+ } else {
208
+ lines.push(row(th.fg('dim', ' credits unavailable'), this.width));
209
+ }
210
+
211
+ // Status by key line
212
+ lines.push(row(` status ${this.rollupStatus.message}`, this.width));
213
+ lines.push(emptyRow(this.width));
214
+
215
+ if (this.keyInfo && this.keyInfo.length > 0) {
216
+ // Sort keys - active first, then spend desc, then usage % desc
217
+ const sortedKeys = sortKeys(this.keyInfo);
218
+
219
+ // Current key section - show for selected key
220
+ // Defensive: ensure index is within bounds before accessing
221
+ const index = Math.max(0, Math.min(this.selectedIndex, sortedKeys.length - 1));
222
+ const currentKey = sortedKeys[index];
223
+ if (currentKey) {
224
+ lines.push(row(` ${th.fg('accent', 'Selected key')}`, this.width));
225
+ lines.push(...this.buildKeyDetails(currentKey, th));
226
+ lines.push(emptyRow(this.width));
227
+ }
228
+
229
+ // All keys section - show all keys in compact format (including current key)
230
+ lines.push(row(` ${th.fg('accent', 'All keys')}`, this.width));
231
+ lines.push(row(` Workspace Key name Active Spend Used `, this.width));
232
+ for (let i = 0; i < sortedKeys.length; i++) {
233
+ lines.push(this.buildCompactKeyRow(sortedKeys[i]!, th, i === this.selectedIndex));
234
+ }
235
+ lines.push(emptyRow(this.width));
236
+ } else {
237
+ // No keys available
238
+ lines.push(row(th.fg('dim', ' No keys available'), this.width));
239
+ }
240
+ lines.push(boxBottom(this.width));
241
+ lines.push(
242
+ plainRow(th.fg('dim', 'Esc to close · r to refresh · ↑/↓ to select'), this.width),
243
+ );
244
+ return lines;
245
+ }
246
+
247
+ private buildKeyDetails(key: KeyInfo, theme: Theme): string[] {
248
+ const lines: string[] = [];
249
+
250
+ // Format status with color
251
+ const statusColor = this.getStatusColor(key.status);
252
+ const statusText = key.status;
253
+ const formattedStatus = theme.fg(statusColor as ThemeColor, statusText);
254
+
255
+ // Format used/limit
256
+ const usedLimitText = formatRemaining(key.used, key.limit);
257
+
258
+ // Format reset cadence
259
+ const resetText = key.resetCadence || 'never';
260
+
261
+ lines.push(row(` name ${truncate(key.name, 30)}`, this.width));
262
+ lines.push(row(` key ${truncate(key.label, 30)}`, this.width));
263
+ lines.push(row(` status ${formattedStatus}`, this.width));
264
+ lines.push(row(` used ${usedLimitText}`, this.width));
265
+ lines.push(row(` reset ${resetText}`, this.width));
266
+ lines.push(row(` byok ${key.byok}`, this.width));
267
+
268
+ return lines;
269
+ }
270
+
271
+ private simplifiedWorkspaceName(workspaceName: string): string {
272
+ return workspaceName.replace(/Workspace$/, '').trim();
273
+ }
274
+
275
+ private buildCompactKeyRow(key: KeyInfo, theme: Theme, isSelected: boolean): string {
276
+ // Format spend
277
+ let spendText: string;
278
+ if (key.disabled) {
279
+ spendText = '-';
280
+ } else {
281
+ spendText = formatCurrency(key.spend);
282
+ }
283
+
284
+ // Color spend based on value
285
+ let spendColor: ThemeColor = 'success';
286
+ if (key.disabled) {
287
+ spendColor = 'dim';
288
+ } else if (key.spend >= 100) {
289
+ spendColor = 'error';
290
+ } else if (key.spend >= 50) {
291
+ spendColor = 'warning';
292
+ }
293
+
294
+ // Pad spend to 8 chars for alignment based on visible width
295
+ const paddedSpend = padToWidth(theme.fg(spendColor as ThemeColor, spendText), 8);
296
+
297
+ // Calculate usage percentage
298
+ let usageText: string;
299
+ if (key.disabled) {
300
+ usageText = '-';
301
+ } else if (key.limit === 0) {
302
+ usageText = '∞';
303
+ } else if (key.limit === undefined) {
304
+ usageText = '-';
305
+ } else if (key.used !== undefined && key.limit !== undefined) {
306
+ const percent = Math.round((key.used / key.limit) * 100);
307
+ usageText = `${percent}%`;
308
+ } else {
309
+ usageText = '-';
310
+ }
311
+
312
+ // Color usage based on percentage
313
+ let usageColor: ThemeColor = 'success';
314
+ if (key.disabled) {
315
+ usageColor = 'dim';
316
+ } else if (usageText === '∞') {
317
+ usageColor = 'error';
318
+ } else {
319
+ const percent = parseInt(usageText, 10);
320
+ if (percent >= 90) {
321
+ usageColor = 'error';
322
+ } else if (percent >= 70) {
323
+ usageColor = 'warning';
324
+ } else {
325
+ usageColor = 'success';
326
+ }
327
+ }
328
+
329
+ // Pad usage to 6 chars for alignment based on visible width
330
+ const paddedUsage = padToWidth(theme.fg(usageColor as ThemeColor, usageText), 5);
331
+
332
+ const enabledIcon = key.disabled
333
+ ? this.theme.fg('error' as ThemeColor, '\u2717')
334
+ : this.theme.fg('success' as ThemeColor, '\u2713');
335
+
336
+ // Truncate name and workspace for compact display
337
+ const name = truncate(key.name, 28);
338
+ const workspace = truncate(this.simplifiedWorkspaceName(key.workspaceName), 20);
339
+
340
+ // Selection indicator
341
+ const selectionIndicator = isSelected ? '●' : '○';
342
+
343
+ return row(
344
+ ` ${selectionIndicator} ${workspace.padEnd(11)} ${name.padEnd(21)} ${enabledIcon} ${paddedSpend} ${paddedUsage}`,
345
+ this.width,
346
+ );
347
+ }
348
+
349
+ private getStatusColor(status: KeyStatus): ThemeColor {
350
+ switch (status) {
351
+ case 'danger':
352
+ return 'error';
353
+ case 'caution':
354
+ return 'warning';
355
+ case 'disabled':
356
+ return 'error';
357
+ case 'partial':
358
+ return 'warning';
359
+ case 'unbounded':
360
+ return 'success';
361
+ default:
362
+ return 'success';
363
+ }
364
+ }
365
+ }
366
+
367
+ // =============================================================================
368
+ // Helper Functions
369
+ // =============================================================================
370
+
371
+ function boxTop(width: number): string {
372
+ return `┌─${'─'.repeat(width - 4)}─┐`;
373
+ }
374
+
375
+ function boxBottom(width: number): string {
376
+ return `└─${'─'.repeat(width - 4)}─┘`;
377
+ }
378
+
379
+ function emptyRow(width: number): string {
380
+ return `│ ${' '.repeat(width - 4)} │`;
381
+ }
382
+
383
+ function row(content: string, width: number): string {
384
+ const innerWidth = width - 4; // -4 for box borders + padding spaces
385
+ const truncated = truncateToVisibleWidth(content, innerWidth);
386
+ return `│ ${truncated}${' '.repeat(innerWidth - getVisibleWidth(truncated))} │`;
387
+ }
388
+
389
+ function plainRow(content: string, width: number): string {
390
+ const innerWidth = width - 2; // -2 for outer spaces
391
+ const truncated = truncateToVisibleWidth(content, innerWidth);
392
+ return ` ${truncated}${' '.repeat(innerWidth - getVisibleWidth(truncated))} `;
393
+ }
394
+
395
+ // Truncate string to visible width, skipping ANSI escape codes
396
+ function truncateToVisibleWidth(str: string, maxVisibleWidth: number): string {
397
+ let visibleSoFar = 0;
398
+ let i = 0;
399
+
400
+ while (i < str.length && visibleSoFar < maxVisibleWidth) {
401
+ const char = str[i];
402
+
403
+ if (char === '\x1b') {
404
+ // Skip ANSI escape sequence
405
+ // eslint-disable-next-line no-control-regex
406
+ const ansiMatch = str.slice(i).match(/^\x1b\[[0-9;]*m/);
407
+ if (ansiMatch) {
408
+ i += ansiMatch[0].length;
409
+ continue;
410
+ }
411
+ }
412
+ visibleSoFar++;
413
+ i++;
414
+ }
415
+ return str.slice(0, i);
416
+ }
417
+
418
+ // Calculate visible width of a string, excluding ANSI escape codes
419
+ function getVisibleWidth(str: string): number {
420
+ // Remove ANSI escape codes
421
+ // eslint-disable-next-line no-control-regex
422
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
423
+ const cleanStr = str.replace(ansiRegex, '');
424
+ return cleanStr.length;
425
+ }
426
+
427
+ // Truncate string to max length, adding ellipsis if needed
428
+ function truncate(str: string, maxLen: number): string {
429
+ if (str.length <= maxLen) return str;
430
+ if (maxLen <= 3) return str.slice(0, maxLen);
431
+ return str.slice(0, maxLen - 3) + '...';
432
+ }
433
+
434
+ // Pad string to fixed width, accounting for ANSI escape codes
435
+ function padToWidth(str: string, width: number): string {
436
+ const visibleWidth = getVisibleWidth(str);
437
+ const paddingNeeded = width - visibleWidth;
438
+ if (paddingNeeded <= 0) return str;
439
+ return str + ' '.repeat(paddingNeeded);
440
+ }
@@ -0,0 +1,48 @@
1
+ // Domain types for /openrouter-account command
2
+
3
+ /** Status of a single key based on usage/limit ratio */
4
+ export type KeyStatus =
5
+ | 'healthy' // <70% used
6
+ | 'caution' // 70-89% used
7
+ | 'danger' // >=90% used
8
+ | 'unbounded' // no key cap
9
+ | 'partial' // missing required fields
10
+ | 'disabled'; // disabled key
11
+
12
+ /** BYOK (Bring-Your-Own-Key) status */
13
+ export type BYOKStatus = 'incl' | 'excl' | '?';
14
+
15
+ /** Reset cadence for key limits */
16
+ export type ResetCadence = 'monthly' | 'daily' | 'never' | 'partial';
17
+
18
+ /** Information about a single OpenRouter key */
19
+ export interface KeyInfo {
20
+ name: string; // Human-readable name (e.g., "Production", "Development")
21
+ label: string; // Key value (masked, e.g., "sk-or-v1-4a0...459")
22
+ status: KeyStatus; // Current health status
23
+ used: number; // Current usage (currency)
24
+ limit?: number; // Key cap (optional)
25
+ remaining?: number; // limit - used
26
+ resetCadence: ResetCadence; // monthly, daily, never, or partial
27
+ byok: BYOKStatus; // incl (true), excl (false), ? (unavailable)
28
+ hash: string; // Key hash for identification
29
+ disabled: boolean; // Whether key is disabled
30
+ workspaceName: string; // Name of the workspace this key belongs to
31
+ spend: number; // Spend associated with this key (in USD)
32
+ }
33
+
34
+ /** Rollup status for the entire account */
35
+ export type RollupStatus =
36
+ | { status: 'unavailable'; message?: never }
37
+ | { status: 'healthy'; message: string }
38
+ | { status: 'caution'; message: string }
39
+ | { status: 'danger'; message: string }
40
+ | { status: 'disabled'; message: string };
41
+
42
+ /** Account credits info */
43
+ export interface AccountCredits {
44
+ totalCredits: number; // Total credit cap
45
+ totalUsage: number; // Total usage
46
+ remaining?: number; // totalCredits - totalUsage
47
+ available?: boolean; // Whether credits are available
48
+ }
@@ -1,4 +1,4 @@
1
- import type { CacheEntry, UsageSummary } from './types.js';
1
+ import type { CacheEntry, UsageSummary, LocalUsageEvent, UsageAggregate } from './types.js';
2
2
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
3
3
  import { aggregateUsage } from './format.js';
4
4
  import { getCredits, getActivity } from './client.js';
@@ -62,11 +62,8 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
62
62
  try {
63
63
  analytics = await getActivity();
64
64
  if (!analytics) hasActivityData = false;
65
- } catch (err) {
65
+ } catch {
66
66
  // getActivity() requires a management key; suppress this expected error
67
- if (!(err instanceof Error) || !err.message.includes('management key')) {
68
- console.log('Activity fetch failed');
69
- }
70
67
  hasActivityData = false;
71
68
  }
72
69
  const timestamp = Date.now();
@@ -106,13 +103,13 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
106
103
  completionTokens: item.completionTokens,
107
104
  reasoningTokens: item.reasoningTokens,
108
105
  cost: item.usage,
109
- }) as any,
106
+ }) as LocalUsageEvent,
110
107
  ),
111
108
  )
112
109
  : ZERO_AGGREGATE;
113
110
 
114
111
  // Read local JSONL after officialThroughDate
115
- const localEvents: any[] = [];
112
+ const localEvents: LocalUsageEvent[] = [];
116
113
  if (officialThroughDate) {
117
114
  // Read from the day after officialThroughDate to today
118
115
  const localFrom = addUtcDays(officialThroughDate, 1);
@@ -123,9 +120,8 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
123
120
  toDateUtc: localTo,
124
121
  });
125
122
  localEvents.push(...localEventsList);
126
- } catch (err) {
123
+ } catch {
127
124
  // Fail open - if local read fails, continue with empty local
128
- console.log('Local usage read failed:', err);
129
125
  }
130
126
  }
131
127
 
@@ -133,7 +129,7 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
133
129
  const localAggregate = aggregateLocal(localEvents);
134
130
 
135
131
  // Combine official + local
136
- const combinedAggregate: any = {
132
+ const combinedAggregate: UsageAggregate = {
137
133
  requests: officialAggregate.requests + localAggregate.requests,
138
134
  promptTokens: officialAggregate.promptTokens + localAggregate.promptTokens,
139
135
  completionTokens: officialAggregate.completionTokens + localAggregate.completionTokens,
@@ -172,11 +168,9 @@ function scheduleRefresh(): void {
172
168
  }
173
169
  } catch {
174
170
  consecutiveFailures++;
175
- console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT})`);
176
171
 
177
172
  // Stop after max retries reached
178
173
  if (consecutiveFailures >= MAX_RETRY_COUNT) {
179
- console.log('Max retries reached, stopping background refresh');
180
174
  stopBackgroundRefresh();
181
175
  // TODO: Fire UI notification for persistent failure
182
176
  return;
@@ -62,7 +62,7 @@ export function aggregateUsage(
62
62
  modelPermaslug: e.model || 'unknown',
63
63
  }));
64
64
 
65
- const allData = [...analytics, ...localItems] as any[];
65
+ const allData = [...analytics, ...localItems];
66
66
 
67
67
  // Build model stats for both 7d and 30d windows
68
68
  // Use allData (combined API + local) for 30d, weekData (combined) for 7d
@@ -10,6 +10,11 @@ import { AuthError } from './client.js';
10
10
  import { UsageOverlayComponent } from './overlay.js';
11
11
  import { formatSessionId, isOpenRouterRequest, type OpenRouterSessionState } from './session.js';
12
12
  import { writeLocalUsage, type LocalUsageEvent } from './local-usage.js';
13
+ import { AccountOverlayComponent } from './account-overlay.js';
14
+ import { computeRollupStatus, sortKeys } from './account-format.js';
15
+ import { getAllKeys, getCurrentKey, getAccountCredits } from './account-client.js';
16
+ import type { KeyInfo } from './account-types.js';
17
+ import type { RollupStatus } from './account-types.js';
13
18
  import crypto from 'node:crypto';
14
19
 
15
20
  // Store the current session state for use in command handlers
@@ -167,6 +172,140 @@ export default function (pi: ExtensionAPI) {
167
172
  ctx.ui.notify(`OpenRouter session_id\n${idToShow}`, 'info');
168
173
  },
169
174
  });
175
+
176
+ pi.registerCommand('openrouter-account', {
177
+ description: 'Show OpenRouter account and key health',
178
+ getArgumentCompletions: () => null,
179
+ handler: async (_args, ctx) => {
180
+ await showAccountOverlay(ctx);
181
+ },
182
+ });
183
+ }
184
+
185
+ async function showAccountOverlay(ctx: ExtensionContext) {
186
+ let error: string | null = null;
187
+ let keyInfo: KeyInfo[] | null = null;
188
+ let credits: number | null = null;
189
+
190
+ try {
191
+ // Try to get all keys with management key
192
+ const allKeys = await getAllKeys();
193
+
194
+ if (allKeys && allKeys.length > 0) {
195
+ keyInfo = allKeys;
196
+ } else {
197
+ // getAllKeys() returns null or empty array when management key isn't available
198
+ // or when the API call fails with 403
199
+ error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
200
+
201
+ // Fall back to current key only
202
+ try {
203
+ const currentKey = await getCurrentKey();
204
+ if (currentKey) {
205
+ keyInfo = [currentKey];
206
+ // Clear the error since we successfully got current key
207
+ error = null;
208
+ } else {
209
+ error = 'Failed to retrieve current key metadata. Check your API key permissions.';
210
+ }
211
+ } catch (err) {
212
+ error = `Failed to retrieve current key: ${(err as Error).message}`;
213
+ }
214
+ }
215
+
216
+ // Try to get account credits
217
+ credits = await getAccountCredits();
218
+
219
+ // Set error if we have no keys and no credits
220
+ if (!keyInfo && !credits) {
221
+ error =
222
+ 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.';
223
+ }
224
+
225
+ // Set error if we have credits but no keys
226
+ if (!keyInfo && credits !== null) {
227
+ error =
228
+ error ||
229
+ 'Key information unavailable. Set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
230
+ }
231
+
232
+ // Compute rollup status
233
+ const rollupStatus = keyInfo
234
+ ? computeRollupStatus(keyInfo)
235
+ : { status: 'unavailable' as const };
236
+
237
+ // Sort keys
238
+ if (keyInfo) {
239
+ const sortedKeys = sortKeys(keyInfo);
240
+ keyInfo = sortedKeys;
241
+ }
242
+
243
+ await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
244
+ } catch (error_) {
245
+ const err = error_ as Error;
246
+ error =
247
+ err instanceof AuthError
248
+ ? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.'
249
+ : `API Error: ${err.message}`;
250
+
251
+ // Try to get current key for overlay even on error
252
+ try {
253
+ const currentKey = await getCurrentKey();
254
+ if (currentKey) {
255
+ keyInfo = [currentKey];
256
+ }
257
+ } catch {
258
+ // Ignore secondary errors
259
+ }
260
+
261
+ const rollupStatus = keyInfo
262
+ ? computeRollupStatus(keyInfo)
263
+ : { status: 'unavailable' as const };
264
+
265
+ await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
266
+ }
267
+ }
268
+
269
+ async function showAccountOverlayComponent(
270
+ ctx: ExtensionContext,
271
+ keyInfo: KeyInfo[] | null,
272
+ credits: number | null,
273
+ rollupStatus: RollupStatus,
274
+ error: string | null,
275
+ ) {
276
+ await ctx.ui.custom<void>(
277
+ (_tui, theme, _keybindings, done) => {
278
+ const overlayComponent = new AccountOverlayComponent(
279
+ keyInfo,
280
+ credits,
281
+ rollupStatus,
282
+ error,
283
+ theme,
284
+ done,
285
+ () => _tui.requestRender(),
286
+ ctx,
287
+ );
288
+
289
+ return {
290
+ handleInput: (data: string) => {
291
+ overlayComponent.handleInput(data);
292
+ _tui.requestRender();
293
+ },
294
+ render: (width: number) => overlayComponent.render(width),
295
+ invalidate: () => overlayComponent.invalidate(),
296
+ dispose: () => {
297
+ overlayComponent.dispose();
298
+ },
299
+ wantsKeyRelease: false,
300
+ };
301
+ },
302
+ {
303
+ overlay: true,
304
+ overlayOptions: {
305
+ width: 100,
306
+ },
307
+ },
308
+ );
170
309
  }
171
310
 
172
311
  async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
@@ -369,8 +369,8 @@ export class UsageOverlayComponent {
369
369
  lines.push(row(` By provider (30d)`, this.width));
370
370
  lines.push(
371
371
  row(
372
- ` ${'Provider'.padEnd(COLS.model)} ${'$'.padStart(COLS.spend)} ` +
373
- `${'tok'.padStart(COLS.tokens)} ${'$/M'.padStart(COLS.costPerM)} ` +
372
+ ` ${'Provider'.padEnd(COLS.model)} ${'30d $'.padStart(COLS.spend)} ` +
373
+ `${'30d tok'.padStart(COLS.tokens)} ${'$/M'.padStart(COLS.costPerM)} ` +
374
374
  `${'reqs'.padStart(COLS.reqs)}`,
375
375
  this.width,
376
376
  ),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
- "description": "OpenRouter usage TUI overlay with caps, burn rate, models, live tracking, and session_id tagging.",
5
+ "description": "Live OpenRouter TUI overlays for spend, credits, key limits, burn rate, model usage, and session tagging.",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "extensions",