@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 +20 -1
- package/extensions/openrouter/account-client.ts +215 -0
- package/extensions/openrouter/account-format.ts +97 -0
- package/extensions/openrouter/account-overlay.ts +440 -0
- package/extensions/openrouter/account-types.ts +48 -0
- package/extensions/openrouter/cache.ts +6 -12
- package/extensions/openrouter/format.ts +1 -1
- package/extensions/openrouter/index.ts +139 -0
- package/extensions/openrouter/overlay.ts +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-openrouter
|
|
2
2
|
|
|
3
|
-
A [Pi](https://pi.dev/) extension for OpenRouter
|
|
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
|
|
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
|
|
106
|
+
}) as LocalUsageEvent,
|
|
110
107
|
),
|
|
111
108
|
)
|
|
112
109
|
: ZERO_AGGREGATE;
|
|
113
110
|
|
|
114
111
|
// Read local JSONL after officialThroughDate
|
|
115
|
-
const localEvents:
|
|
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
|
|
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:
|
|
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]
|
|
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.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "OpenRouter
|
|
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",
|