@robhowley/pi-openrouter 0.10.0 → 0.11.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/README.md +18 -3
- package/extensions/openrouter/__tests__/account-client.test.ts +488 -0
- package/extensions/openrouter/__tests__/account-overlay.test.ts +683 -0
- package/extensions/openrouter/__tests__/api-key-commands.test.ts +231 -0
- package/extensions/openrouter/__tests__/commands.test.ts +225 -3
- package/extensions/openrouter/__tests__/normalizers.test.ts +86 -3
- package/extensions/openrouter/account-client.ts +275 -28
- package/extensions/openrouter/account-overlay.ts +417 -60
- package/extensions/openrouter/account-types.ts +10 -3
- package/extensions/openrouter/api-key-commands.ts +287 -0
- package/extensions/openrouter/commands.ts +123 -11
- package/extensions/openrouter/normalizers.ts +22 -5
- package/package.json +4 -3
|
@@ -1,14 +1,19 @@
|
|
|
1
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';
|
|
2
|
+
import type { ExtensionContext, Theme, ThemeColor } from '@mariozechner/pi-coding-agent';
|
|
3
|
+
import type { CurrentKeyRelation, KeyInfo, KeyStatus, RollupStatus } from './account-types.js';
|
|
4
4
|
import {
|
|
5
5
|
computeRollupStatus,
|
|
6
6
|
formatCurrency,
|
|
7
7
|
formatRemaining,
|
|
8
8
|
sortKeys,
|
|
9
9
|
} from './account-format.js';
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
getAccountCredits,
|
|
12
|
+
getAllKeys,
|
|
13
|
+
getCurrentKey,
|
|
14
|
+
resolveCurrentKeyRelation,
|
|
15
|
+
setApiKeyDisabled,
|
|
16
|
+
} from './account-client.js';
|
|
12
17
|
|
|
13
18
|
// =============================================================================
|
|
14
19
|
// Constants
|
|
@@ -16,6 +21,10 @@ import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
|
16
21
|
|
|
17
22
|
const MIN_WIDTH = 65;
|
|
18
23
|
|
|
24
|
+
type ToggleGuard =
|
|
25
|
+
| { canToggle: true; action: 'enable' | 'disable'; hash: string }
|
|
26
|
+
| { canToggle: false; reason: string; tone: ThemeColor };
|
|
27
|
+
|
|
19
28
|
// =============================================================================
|
|
20
29
|
// Account Overlay Component
|
|
21
30
|
// =============================================================================
|
|
@@ -34,6 +43,12 @@ export class AccountOverlayComponent {
|
|
|
34
43
|
private requestRender: () => void;
|
|
35
44
|
private isDisposed = false;
|
|
36
45
|
private ctx: ExtensionContext | null = null;
|
|
46
|
+
private confirmationHash: string | null = null;
|
|
47
|
+
private pendingToggleHash: string | null = null;
|
|
48
|
+
private inlineMessage: string | null = null;
|
|
49
|
+
private inlineMessageTone: ThemeColor = 'dim';
|
|
50
|
+
private canManageKeys: boolean;
|
|
51
|
+
private currentKeyRelation: CurrentKeyRelation | undefined;
|
|
37
52
|
|
|
38
53
|
constructor(
|
|
39
54
|
keyInfo: KeyInfo[] | null,
|
|
@@ -44,6 +59,8 @@ export class AccountOverlayComponent {
|
|
|
44
59
|
onClose: () => void,
|
|
45
60
|
requestRender: () => void,
|
|
46
61
|
ctx?: ExtensionContext,
|
|
62
|
+
canManageKeys = true,
|
|
63
|
+
currentKeyRelation?: CurrentKeyRelation,
|
|
47
64
|
) {
|
|
48
65
|
this.theme = theme;
|
|
49
66
|
this.onClose = onClose;
|
|
@@ -54,6 +71,8 @@ export class AccountOverlayComponent {
|
|
|
54
71
|
this.error = error;
|
|
55
72
|
this.selectedIndex = 0;
|
|
56
73
|
this.ctx = ctx || null;
|
|
74
|
+
this.canManageKeys = canManageKeys;
|
|
75
|
+
this.currentKeyRelation = currentKeyRelation;
|
|
57
76
|
this.width = this.calculateWidth();
|
|
58
77
|
this.lines = this.buildLines();
|
|
59
78
|
|
|
@@ -72,25 +91,54 @@ export class AccountOverlayComponent {
|
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
handleInput(data: string): void {
|
|
75
|
-
|
|
76
|
-
|
|
94
|
+
if (this.pendingToggleHash) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (matchesKey(data, 'ctrl+c') || data === 'q') {
|
|
77
99
|
this.onClose();
|
|
78
100
|
return;
|
|
79
101
|
}
|
|
80
102
|
|
|
81
|
-
|
|
103
|
+
if (matchesKey(data, 'escape')) {
|
|
104
|
+
if (this.confirmationHash) {
|
|
105
|
+
this.confirmationHash = null;
|
|
106
|
+
this.invalidate();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.onClose();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (matchesKey(data, 'enter') || matchesKey(data, 'return')) {
|
|
114
|
+
if (this.confirmationHash) {
|
|
115
|
+
void this.confirmToggle();
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.confirmationHash) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
82
124
|
if (matchesKey(data, 'r')) {
|
|
83
|
-
this.refresh();
|
|
125
|
+
void this.refresh();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (matchesKey(data, 't')) {
|
|
130
|
+
this.openToggleConfirmation();
|
|
84
131
|
return;
|
|
85
132
|
}
|
|
86
133
|
|
|
87
|
-
// Key selection with arrow keys
|
|
88
134
|
if (this.keyInfo && this.keyInfo.length > 0) {
|
|
89
135
|
if (matchesKey(data, 'up')) {
|
|
90
136
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
137
|
+
this.inlineMessage = null;
|
|
91
138
|
this.invalidate();
|
|
92
139
|
} else if (matchesKey(data, 'down')) {
|
|
93
140
|
this.selectedIndex = Math.min(this.keyInfo.length - 1, this.selectedIndex + 1);
|
|
141
|
+
this.inlineMessage = null;
|
|
94
142
|
this.invalidate();
|
|
95
143
|
}
|
|
96
144
|
}
|
|
@@ -99,7 +147,6 @@ export class AccountOverlayComponent {
|
|
|
99
147
|
wantsKeyRelease = false;
|
|
100
148
|
|
|
101
149
|
render(width: number): string[] {
|
|
102
|
-
// Center the overlay if terminal is wider
|
|
103
150
|
const padding = Math.max(0, Math.floor((width - this.width) / 2));
|
|
104
151
|
const pad = ' '.repeat(padding);
|
|
105
152
|
|
|
@@ -107,13 +154,8 @@ export class AccountOverlayComponent {
|
|
|
107
154
|
}
|
|
108
155
|
|
|
109
156
|
invalidate(): void {
|
|
110
|
-
if (this.isDisposed) return;
|
|
111
|
-
// Rebuild lines to update "last refreshed" time
|
|
112
157
|
this.lines = this.buildLines();
|
|
113
|
-
|
|
114
|
-
if (this.keyInfo && this.selectedIndex >= this.keyInfo.length) {
|
|
115
|
-
this.selectedIndex = this.keyInfo.length - 1;
|
|
116
|
-
}
|
|
158
|
+
this.clampSelectedIndex();
|
|
117
159
|
if (!this.isDisposed) {
|
|
118
160
|
this.requestRender();
|
|
119
161
|
}
|
|
@@ -122,8 +164,10 @@ export class AccountOverlayComponent {
|
|
|
122
164
|
async refresh(): Promise<void> {
|
|
123
165
|
if (this.isDisposed || !this.ctx) return;
|
|
124
166
|
|
|
167
|
+
const selectedHash = this.getSelectedKeyHash();
|
|
168
|
+
|
|
125
169
|
try {
|
|
126
|
-
const
|
|
170
|
+
const keyInventory = await getAllKeys();
|
|
127
171
|
let credits: number | null = null;
|
|
128
172
|
try {
|
|
129
173
|
credits = await getAccountCredits();
|
|
@@ -133,34 +177,46 @@ export class AccountOverlayComponent {
|
|
|
133
177
|
|
|
134
178
|
let error: string | null = null;
|
|
135
179
|
let keyInfo: KeyInfo[] | null = null;
|
|
180
|
+
let currentKeyRelation: CurrentKeyRelation | undefined;
|
|
136
181
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
182
|
+
this.canManageKeys = keyInventory.canManageKeys;
|
|
183
|
+
|
|
184
|
+
if (keyInventory.keys.length > 0) {
|
|
185
|
+
keyInfo = keyInventory.keys;
|
|
186
|
+
try {
|
|
187
|
+
currentKeyRelation = await resolveCurrentKeyRelation(keyInfo);
|
|
188
|
+
} catch {
|
|
189
|
+
// Safe gating: disabling stays blocked until current-key identity is available.
|
|
190
|
+
}
|
|
191
|
+
} else if (keyInventory.degradedReason === 'management-unavailable') {
|
|
140
192
|
error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
|
|
141
193
|
try {
|
|
142
194
|
const currentKey = await getCurrentKey();
|
|
143
195
|
if (currentKey) {
|
|
144
196
|
keyInfo = [currentKey];
|
|
197
|
+
error = null;
|
|
145
198
|
}
|
|
146
199
|
} catch {
|
|
147
200
|
// Ignore secondary errors
|
|
148
201
|
}
|
|
202
|
+
} else if (keyInventory.degradedReason === 'missing-api-key') {
|
|
203
|
+
error =
|
|
204
|
+
'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.';
|
|
149
205
|
}
|
|
150
206
|
|
|
151
207
|
const rollupStatus = keyInfo
|
|
152
208
|
? computeRollupStatus(keyInfo)
|
|
153
209
|
: { status: 'unavailable' as const };
|
|
154
210
|
|
|
155
|
-
// Update state
|
|
156
211
|
this.keyInfo = keyInfo;
|
|
157
212
|
this.credits = credits;
|
|
158
213
|
this.rollupStatus = rollupStatus;
|
|
159
214
|
this.error = error;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.
|
|
215
|
+
this.currentKeyRelation = currentKeyRelation;
|
|
216
|
+
this.confirmationHash = null;
|
|
217
|
+
this.pendingToggleHash = null;
|
|
163
218
|
this.width = this.calculateWidth();
|
|
219
|
+
this.restoreSelectedIndexByHash(selectedHash);
|
|
164
220
|
this.lines = this.buildLines();
|
|
165
221
|
|
|
166
222
|
this.requestRender();
|
|
@@ -170,7 +226,7 @@ export class AccountOverlayComponent {
|
|
|
170
226
|
}
|
|
171
227
|
|
|
172
228
|
private calculateWidth(): number {
|
|
173
|
-
return
|
|
229
|
+
return MIN_WIDTH;
|
|
174
230
|
}
|
|
175
231
|
|
|
176
232
|
/** Get the header row for the account overlay */
|
|
@@ -199,38 +255,44 @@ export class AccountOverlayComponent {
|
|
|
199
255
|
lines.push(this.getAccountHeaderRow());
|
|
200
256
|
lines.push(emptyRow(this.width));
|
|
201
257
|
|
|
202
|
-
// Total spend line (sum of all key spends)
|
|
203
258
|
if (this.keyInfo && this.keyInfo.length > 0) {
|
|
204
259
|
const totalSpend = this.keyInfo.reduce((sum, k) => sum + k.spend, 0);
|
|
205
260
|
lines.push(row(` usage ${formatCurrency(totalSpend)}`, this.width));
|
|
206
261
|
}
|
|
207
262
|
|
|
208
|
-
// Credits line
|
|
209
263
|
if (this.credits !== null) {
|
|
210
264
|
lines.push(row(` credits ${formatCurrency(this.credits)}`, this.width));
|
|
211
265
|
} else {
|
|
212
266
|
lines.push(row(th.fg('dim', ' credits unavailable'), this.width));
|
|
213
267
|
}
|
|
214
268
|
|
|
215
|
-
|
|
216
|
-
|
|
269
|
+
const rollupMessage =
|
|
270
|
+
this.rollupStatus.status === 'unavailable' ? 'unavailable' : this.rollupStatus.message;
|
|
271
|
+
lines.push(row(` status ${rollupMessage}`, this.width));
|
|
217
272
|
lines.push(emptyRow(this.width));
|
|
218
273
|
|
|
274
|
+
let selectedToggleAction: 'enable' | 'disable' | null = null;
|
|
275
|
+
|
|
219
276
|
if (this.keyInfo && this.keyInfo.length > 0) {
|
|
220
|
-
// Sort keys - active first, then spend desc, then usage % desc
|
|
221
277
|
const sortedKeys = sortKeys(this.keyInfo);
|
|
278
|
+
this.clampSelectedIndex(sortedKeys);
|
|
279
|
+
const currentKey = sortedKeys[this.selectedIndex] ?? sortedKeys[0] ?? null;
|
|
222
280
|
|
|
223
|
-
// Current key section - show for selected key
|
|
224
|
-
// Defensive: ensure index is within bounds before accessing
|
|
225
|
-
const index = Math.max(0, Math.min(this.selectedIndex, sortedKeys.length - 1));
|
|
226
|
-
const currentKey = sortedKeys[index];
|
|
227
281
|
if (currentKey) {
|
|
282
|
+
const toggleGuard = this.getToggleGuard(currentKey);
|
|
283
|
+
if (toggleGuard.canToggle) {
|
|
284
|
+
selectedToggleAction = toggleGuard.action;
|
|
285
|
+
}
|
|
286
|
+
|
|
228
287
|
lines.push(row(` ${th.fg('accent', 'Selected key')}`, this.width));
|
|
229
288
|
lines.push(...this.buildKeyDetails(currentKey, th));
|
|
289
|
+
if (!toggleGuard.canToggle) {
|
|
290
|
+
lines.push(row(th.fg('dim', ` readonly ${toggleGuard.reason}`), this.width));
|
|
291
|
+
}
|
|
292
|
+
lines.push(...this.buildInlineStateLines(th));
|
|
230
293
|
lines.push(emptyRow(this.width));
|
|
231
294
|
}
|
|
232
295
|
|
|
233
|
-
// All keys section - show all keys in compact format (including current key)
|
|
234
296
|
lines.push(row(` ${th.fg('accent', 'All keys')}`, this.width));
|
|
235
297
|
lines.push(row(` Workspace Key name Active Spend Used `, this.width));
|
|
236
298
|
for (let i = 0; i < sortedKeys.length; i++) {
|
|
@@ -238,28 +300,22 @@ export class AccountOverlayComponent {
|
|
|
238
300
|
}
|
|
239
301
|
lines.push(emptyRow(this.width));
|
|
240
302
|
} else {
|
|
241
|
-
// No keys available
|
|
242
303
|
lines.push(row(th.fg('dim', ' No keys available'), this.width));
|
|
243
304
|
}
|
|
305
|
+
|
|
244
306
|
lines.push(boxBottom(this.width));
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
307
|
+
const footer = selectedToggleAction
|
|
308
|
+
? `Esc close · r refresh · ↑/↓ select · t ${selectedToggleAction}`
|
|
309
|
+
: 'Esc close · r refresh · ↑/↓ select';
|
|
310
|
+
lines.push(plainRow(th.fg('dim', footer), this.width));
|
|
248
311
|
return lines;
|
|
249
312
|
}
|
|
250
313
|
|
|
251
314
|
private buildKeyDetails(key: KeyInfo, theme: Theme): string[] {
|
|
252
315
|
const lines: string[] = [];
|
|
253
|
-
|
|
254
|
-
// Format status with color
|
|
255
316
|
const statusColor = this.getStatusColor(key.status);
|
|
256
|
-
const
|
|
257
|
-
const formattedStatus = theme.fg(statusColor as ThemeColor, statusText);
|
|
258
|
-
|
|
259
|
-
// Format used/limit
|
|
317
|
+
const formattedStatus = theme.fg(statusColor, key.status);
|
|
260
318
|
const usedLimitText = formatRemaining(key.used, key.limit);
|
|
261
|
-
|
|
262
|
-
// Format reset cadence
|
|
263
319
|
const resetText = key.resetCadence || 'never';
|
|
264
320
|
|
|
265
321
|
lines.push(row(` name ${truncate(key.name, 30)}`, this.width));
|
|
@@ -272,12 +328,233 @@ export class AccountOverlayComponent {
|
|
|
272
328
|
return lines;
|
|
273
329
|
}
|
|
274
330
|
|
|
331
|
+
private buildInlineStateLines(theme: Theme): string[] {
|
|
332
|
+
const lines: string[] = [];
|
|
333
|
+
const targetHash = this.pendingToggleHash ?? this.confirmationHash;
|
|
334
|
+
const targetKey = targetHash ? this.findKeyByHash(targetHash) : null;
|
|
335
|
+
|
|
336
|
+
if (this.confirmationHash && targetKey) {
|
|
337
|
+
const action = targetKey.disabled ? 'enable' : 'disable';
|
|
338
|
+
lines.push(
|
|
339
|
+
row(
|
|
340
|
+
theme.fg(
|
|
341
|
+
'warning',
|
|
342
|
+
` toggle Press Enter to ${action} ${truncate(targetKey.name, 20)}`,
|
|
343
|
+
),
|
|
344
|
+
this.width,
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
lines.push(row(theme.fg('dim', ' Esc to cancel'), this.width));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (this.pendingToggleHash && targetKey) {
|
|
351
|
+
const action = targetKey.disabled ? 'Enabling' : 'Disabling';
|
|
352
|
+
lines.push(
|
|
353
|
+
row(
|
|
354
|
+
theme.fg('dim', ` status ${action} ${truncate(targetKey.name, 20)}...`),
|
|
355
|
+
this.width,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (this.inlineMessage) {
|
|
361
|
+
const label = this.inlineMessageTone === 'error' ? 'error' : 'status';
|
|
362
|
+
lines.push(
|
|
363
|
+
row(theme.fg(this.inlineMessageTone, ` ${label} ${this.inlineMessage}`), this.width),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return lines;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private openToggleConfirmation(): void {
|
|
371
|
+
const selectedKey = this.getSelectedKey();
|
|
372
|
+
if (!selectedKey) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const toggleGuard = this.getToggleGuard(selectedKey);
|
|
377
|
+
if (!toggleGuard.canToggle) {
|
|
378
|
+
this.setInlineMessage(toggleGuard.reason, toggleGuard.tone);
|
|
379
|
+
this.invalidate();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.confirmationHash = toggleGuard.hash;
|
|
384
|
+
this.inlineMessage = null;
|
|
385
|
+
this.invalidate();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async confirmToggle(): Promise<void> {
|
|
389
|
+
if (!this.confirmationHash || this.pendingToggleHash || !this.keyInfo) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const currentKey = this.findKeyByHash(this.confirmationHash);
|
|
394
|
+
if (!currentKey) {
|
|
395
|
+
this.confirmationHash = null;
|
|
396
|
+
this.setInlineMessage('Selected key is no longer available.', 'error');
|
|
397
|
+
this.invalidate();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const toggleGuard = this.getToggleGuard(currentKey);
|
|
402
|
+
if (!toggleGuard.canToggle) {
|
|
403
|
+
this.confirmationHash = null;
|
|
404
|
+
this.setInlineMessage(toggleGuard.reason, toggleGuard.tone);
|
|
405
|
+
this.invalidate();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const targetHash = toggleGuard.hash;
|
|
410
|
+
this.confirmationHash = null;
|
|
411
|
+
this.pendingToggleHash = targetHash;
|
|
412
|
+
this.inlineMessage = null;
|
|
413
|
+
this.invalidate();
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const updatedState = await setApiKeyDisabled(targetHash, !currentKey.disabled);
|
|
417
|
+
|
|
418
|
+
const updatedKey: KeyInfo = {
|
|
419
|
+
...currentKey,
|
|
420
|
+
...updatedState,
|
|
421
|
+
hash: targetHash,
|
|
422
|
+
workspaceName: currentKey.workspaceName,
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
this.keyInfo = this.keyInfo.map((key) => (key.hash === targetHash ? updatedKey : key));
|
|
426
|
+
this.rollupStatus = computeRollupStatus(this.keyInfo);
|
|
427
|
+
this.pendingToggleHash = null;
|
|
428
|
+
this.setInlineMessage(
|
|
429
|
+
`${updatedKey.name} ${updatedKey.disabled ? 'disabled' : 'enabled'}.`,
|
|
430
|
+
'success',
|
|
431
|
+
);
|
|
432
|
+
this.restoreSelectedIndexByHash(targetHash);
|
|
433
|
+
this.invalidate();
|
|
434
|
+
} catch (error_) {
|
|
435
|
+
this.pendingToggleHash = null;
|
|
436
|
+
const action = currentKey.disabled ? 'enable' : 'disable';
|
|
437
|
+
this.setInlineMessage(
|
|
438
|
+
`Failed to ${action} ${currentKey.name}: ${getSafeToggleErrorMessage(error_)}`,
|
|
439
|
+
'error',
|
|
440
|
+
);
|
|
441
|
+
this.restoreSelectedIndexByHash(targetHash);
|
|
442
|
+
this.invalidate();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private setInlineMessage(message: string, tone: ThemeColor): void {
|
|
447
|
+
this.inlineMessage = message;
|
|
448
|
+
this.inlineMessageTone = tone;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private getToggleGuard(key: KeyInfo): ToggleGuard {
|
|
452
|
+
if (!this.canManageKeys) {
|
|
453
|
+
return {
|
|
454
|
+
canToggle: false,
|
|
455
|
+
reason: 'Set OPENROUTER_MANAGEMENT_KEY to toggle keys.',
|
|
456
|
+
tone: 'warning',
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!this.hasTrustedHash(key)) {
|
|
461
|
+
return {
|
|
462
|
+
canToggle: false,
|
|
463
|
+
reason: 'This row is not backed by key inventory metadata.',
|
|
464
|
+
tone: 'warning',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const action = key.disabled ? 'enable' : 'disable';
|
|
469
|
+
if (action === 'enable') {
|
|
470
|
+
return { canToggle: true, action, hash: key.hash };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
switch (this.currentKeyRelation?.kind) {
|
|
474
|
+
case 'inventory-match':
|
|
475
|
+
if (key.hash === this.currentKeyRelation.hash) {
|
|
476
|
+
return {
|
|
477
|
+
canToggle: false,
|
|
478
|
+
reason: 'Cannot disable the active management key.',
|
|
479
|
+
tone: 'warning',
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return { canToggle: true, action, hash: key.hash };
|
|
483
|
+
case 'external-provisioning':
|
|
484
|
+
return { canToggle: true, action, hash: key.hash };
|
|
485
|
+
case 'ambiguous-label':
|
|
486
|
+
if (this.currentKeyRelation.matchingHashes.includes(key.hash)) {
|
|
487
|
+
return {
|
|
488
|
+
canToggle: false,
|
|
489
|
+
reason: 'Multiple keys match the current key label.',
|
|
490
|
+
tone: 'warning',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return { canToggle: true, action, hash: key.hash };
|
|
494
|
+
default:
|
|
495
|
+
return {
|
|
496
|
+
canToggle: false,
|
|
497
|
+
reason: 'Cannot verify current key matches this row.',
|
|
498
|
+
tone: 'warning',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private hasTrustedHash(key: KeyInfo): key is KeyInfo & { hash: string } {
|
|
504
|
+
return typeof key.hash === 'string' && key.hash.trim() !== '';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private getSelectedKey(sortedKeys?: KeyInfo[]): KeyInfo | null {
|
|
508
|
+
const keys = sortedKeys ?? this.getSortedKeys();
|
|
509
|
+
if (keys.length === 0) return null;
|
|
510
|
+
const index = Math.max(0, Math.min(this.selectedIndex, keys.length - 1));
|
|
511
|
+
return keys[index] ?? null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private getSelectedKeyHash(): string | null {
|
|
515
|
+
return this.getSelectedKey()?.hash ?? null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private findKeyByHash(hash: string): KeyInfo | null {
|
|
519
|
+
if (!this.keyInfo) return null;
|
|
520
|
+
return this.keyInfo.find((key) => key.hash === hash) ?? null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private getSortedKeys(): KeyInfo[] {
|
|
524
|
+
return this.keyInfo ? sortKeys(this.keyInfo) : [];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private restoreSelectedIndexByHash(hash: string | null): void {
|
|
528
|
+
const sortedKeys = this.getSortedKeys();
|
|
529
|
+
if (!hash || sortedKeys.length === 0) {
|
|
530
|
+
this.selectedIndex = 0;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const index = sortedKeys.findIndex((key) => key.hash === hash);
|
|
535
|
+
this.selectedIndex = index >= 0 ? index : 0;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private clampSelectedIndex(sortedKeys?: KeyInfo[]): void {
|
|
539
|
+
const keys = sortedKeys ?? this.getSortedKeys();
|
|
540
|
+
if (keys.length === 0) {
|
|
541
|
+
this.selectedIndex = 0;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (this.selectedIndex >= keys.length) {
|
|
546
|
+
this.selectedIndex = keys.length - 1;
|
|
547
|
+
}
|
|
548
|
+
if (this.selectedIndex < 0) {
|
|
549
|
+
this.selectedIndex = 0;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
275
553
|
private simplifiedWorkspaceName(workspaceName: string): string {
|
|
276
554
|
return workspaceName.replace(/Workspace$/, '').trim();
|
|
277
555
|
}
|
|
278
556
|
|
|
279
557
|
private buildCompactKeyRow(key: KeyInfo, theme: Theme, isSelected: boolean): string {
|
|
280
|
-
// Format spend
|
|
281
558
|
let spendText: string;
|
|
282
559
|
if (key.disabled) {
|
|
283
560
|
spendText = '-';
|
|
@@ -285,7 +562,6 @@ export class AccountOverlayComponent {
|
|
|
285
562
|
spendText = formatCurrency(key.spend);
|
|
286
563
|
}
|
|
287
564
|
|
|
288
|
-
// Color spend based on value
|
|
289
565
|
let spendColor: ThemeColor = 'success';
|
|
290
566
|
if (key.disabled) {
|
|
291
567
|
spendColor = 'dim';
|
|
@@ -295,10 +571,8 @@ export class AccountOverlayComponent {
|
|
|
295
571
|
spendColor = 'warning';
|
|
296
572
|
}
|
|
297
573
|
|
|
298
|
-
|
|
299
|
-
const paddedSpend = padToWidth(theme.fg(spendColor as ThemeColor, spendText), 8);
|
|
574
|
+
const paddedSpend = padToWidth(theme.fg(spendColor, spendText), 8);
|
|
300
575
|
|
|
301
|
-
// Calculate usage percentage
|
|
302
576
|
let usageText: string;
|
|
303
577
|
if (key.disabled) {
|
|
304
578
|
usageText = '-';
|
|
@@ -313,7 +587,6 @@ export class AccountOverlayComponent {
|
|
|
313
587
|
usageText = '-';
|
|
314
588
|
}
|
|
315
589
|
|
|
316
|
-
// Color usage based on percentage
|
|
317
590
|
let usageColor: ThemeColor = 'success';
|
|
318
591
|
if (key.disabled) {
|
|
319
592
|
usageColor = 'dim';
|
|
@@ -330,18 +603,14 @@ export class AccountOverlayComponent {
|
|
|
330
603
|
}
|
|
331
604
|
}
|
|
332
605
|
|
|
333
|
-
|
|
334
|
-
const paddedUsage = padToWidth(theme.fg(usageColor as ThemeColor, usageText), 5);
|
|
606
|
+
const paddedUsage = padToWidth(theme.fg(usageColor, usageText), 5);
|
|
335
607
|
|
|
336
608
|
const enabledIcon = key.disabled
|
|
337
|
-
? this.theme.fg('error'
|
|
338
|
-
: this.theme.fg('success'
|
|
609
|
+
? this.theme.fg('error', '\u2717')
|
|
610
|
+
: this.theme.fg('success', '\u2713');
|
|
339
611
|
|
|
340
|
-
// Truncate name and workspace for compact display
|
|
341
612
|
const name = truncate(key.name, 28);
|
|
342
613
|
const workspace = truncate(this.simplifiedWorkspaceName(key.workspaceName), 20);
|
|
343
|
-
|
|
344
|
-
// Selection indicator
|
|
345
614
|
const selectionIndicator = isSelected ? '●' : '○';
|
|
346
615
|
|
|
347
616
|
return row(
|
|
@@ -372,6 +641,94 @@ export class AccountOverlayComponent {
|
|
|
372
641
|
// Helper Functions
|
|
373
642
|
// =============================================================================
|
|
374
643
|
|
|
644
|
+
type ToggleErrorKind =
|
|
645
|
+
| 'management-key-required'
|
|
646
|
+
| 'management-key-permissions'
|
|
647
|
+
| 'selected-key-invalid'
|
|
648
|
+
| 'service-unavailable'
|
|
649
|
+
| 'unknown';
|
|
650
|
+
|
|
651
|
+
function getSafeToggleErrorMessage(error: unknown): string {
|
|
652
|
+
switch (getToggleErrorKind(error)) {
|
|
653
|
+
case 'management-key-required':
|
|
654
|
+
return 'Set OPENROUTER_MANAGEMENT_KEY to a valid management key, then refresh and try again.';
|
|
655
|
+
case 'management-key-permissions':
|
|
656
|
+
return 'OPENROUTER_MANAGEMENT_KEY does not have permission to update keys. Set it to a valid management key and refresh.';
|
|
657
|
+
case 'selected-key-invalid':
|
|
658
|
+
return 'OpenRouter could not match the selected key. Refresh the account view and try again.';
|
|
659
|
+
case 'service-unavailable':
|
|
660
|
+
return 'OpenRouter could not update the selected key right now. Retry in a moment and refresh.';
|
|
661
|
+
default:
|
|
662
|
+
return 'OpenRouter could not update the selected key. Refresh and try again.';
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function getToggleErrorKind(error: unknown): ToggleErrorKind {
|
|
667
|
+
const statusCode = getErrorStatusCode(error);
|
|
668
|
+
const message = getErrorMessage(error);
|
|
669
|
+
const errorName = getErrorName(error);
|
|
670
|
+
|
|
671
|
+
if (
|
|
672
|
+
statusCode === 401 ||
|
|
673
|
+
errorName === 'AuthError' ||
|
|
674
|
+
/OPENROUTER_MANAGEMENT_KEY is required/i.test(message)
|
|
675
|
+
) {
|
|
676
|
+
return 'management-key-required';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (statusCode === 403 || /does not have permission/i.test(message)) {
|
|
680
|
+
return 'management-key-permissions';
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (statusCode === 400 || statusCode === 404) {
|
|
684
|
+
return 'selected-key-invalid';
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (statusCode === 429 || (statusCode !== undefined && statusCode >= 500)) {
|
|
688
|
+
return 'service-unavailable';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return 'unknown';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function getErrorStatusCode(error: unknown): number | undefined {
|
|
695
|
+
if (typeof error !== 'object' || error === null) {
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const rawStatus =
|
|
700
|
+
(error as { statusCode?: number | string; status?: number | string }).statusCode ??
|
|
701
|
+
(error as { statusCode?: number | string; status?: number | string }).status;
|
|
702
|
+
|
|
703
|
+
if (typeof rawStatus === 'number' && Number.isFinite(rawStatus)) {
|
|
704
|
+
return rawStatus;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (typeof rawStatus === 'string') {
|
|
708
|
+
const parsed = Number(rawStatus);
|
|
709
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function getErrorName(error: unknown): string | undefined {
|
|
716
|
+
if (error instanceof Error) {
|
|
717
|
+
return error.name;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (typeof error !== 'object' || error === null) {
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const name = (error as { name?: unknown }).name;
|
|
725
|
+
return typeof name === 'string' ? name : undefined;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function getErrorMessage(error: unknown): string {
|
|
729
|
+
return error instanceof Error ? error.message : String(error);
|
|
730
|
+
}
|
|
731
|
+
|
|
375
732
|
function boxTop(width: number): string {
|
|
376
733
|
return `┌─${'─'.repeat(width - 4)}─┐`;
|
|
377
734
|
}
|