@robhowley/pi-openrouter 0.10.0 → 0.11.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 +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 +2 -2
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Theme } from '@mariozechner/pi-coding-agent';
|
|
3
|
+
import type { CurrentKeyRelation, KeyInfo, RollupStatus } from '../account-types.js';
|
|
4
|
+
|
|
5
|
+
const tuiMocks = vi.hoisted(() => ({
|
|
6
|
+
matchesKey: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const accountClientMocks = vi.hoisted(() => ({
|
|
10
|
+
getAllKeys: vi.fn(),
|
|
11
|
+
getCurrentKey: vi.fn(),
|
|
12
|
+
resolveCurrentKeyRelation: vi.fn(),
|
|
13
|
+
getAccountCredits: vi.fn(),
|
|
14
|
+
setApiKeyDisabled: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('@mariozechner/pi-tui', () => ({
|
|
18
|
+
matchesKey: tuiMocks.matchesKey,
|
|
19
|
+
truncateToWidth: (text: string) => text,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../account-client.js', () => ({
|
|
23
|
+
getAllKeys: accountClientMocks.getAllKeys,
|
|
24
|
+
getCurrentKey: accountClientMocks.getCurrentKey,
|
|
25
|
+
resolveCurrentKeyRelation: accountClientMocks.resolveCurrentKeyRelation,
|
|
26
|
+
getAccountCredits: accountClientMocks.getAccountCredits,
|
|
27
|
+
setApiKeyDisabled: accountClientMocks.setApiKeyDisabled,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { AccountOverlayComponent } from '../account-overlay.js';
|
|
31
|
+
|
|
32
|
+
function createIdentityTheme(): Theme {
|
|
33
|
+
return {
|
|
34
|
+
bold: (text: string) => text,
|
|
35
|
+
fg: (_style: string, text: string) => text,
|
|
36
|
+
} as Theme;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createKey(overrides: Partial<KeyInfo> = {}): KeyInfo {
|
|
40
|
+
return {
|
|
41
|
+
name: 'Primary',
|
|
42
|
+
label: 'sk-or-v1-123',
|
|
43
|
+
status: 'healthy',
|
|
44
|
+
used: 10,
|
|
45
|
+
remaining: 90,
|
|
46
|
+
limit: 100,
|
|
47
|
+
resetCadence: 'monthly',
|
|
48
|
+
byok: 'incl',
|
|
49
|
+
hash: 'hash-1',
|
|
50
|
+
disabled: false,
|
|
51
|
+
workspaceName: 'Main Workspace',
|
|
52
|
+
spend: 10,
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createInventoryMatchRelation(
|
|
58
|
+
hash = 'hash-management',
|
|
59
|
+
label = 'sk-or-v1-management',
|
|
60
|
+
): CurrentKeyRelation {
|
|
61
|
+
return { kind: 'inventory-match', hash, label };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createExternalProvisioningRelation(label = 'sk-or-v1-provisioning'): CurrentKeyRelation {
|
|
65
|
+
return { kind: 'external-provisioning', label };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createKeyInventory(
|
|
69
|
+
keys: KeyInfo[] = [createKey()],
|
|
70
|
+
options: { canManageKeys?: boolean; degradedReason?: string } = {},
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
keys,
|
|
74
|
+
canManageKeys: options.canManageKeys ?? true,
|
|
75
|
+
...(options.degradedReason ? { degradedReason: options.degradedReason } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createDeferred<T>() {
|
|
80
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
81
|
+
const promise = new Promise<T>((resolvePromise) => {
|
|
82
|
+
resolve = resolvePromise;
|
|
83
|
+
});
|
|
84
|
+
return { promise, resolve };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createApiError(message: string, statusCode: number): Error & { statusCode: number } {
|
|
88
|
+
return Object.assign(new Error(message), { name: 'ApiError', statusCode });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createAuthError(message: string): Error & {
|
|
92
|
+
name: string;
|
|
93
|
+
} {
|
|
94
|
+
return Object.assign(new Error(message), { name: 'AuthError' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderText(component: AccountOverlayComponent): string {
|
|
98
|
+
return component.render(120).join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function installSimpleKeyMatcher(): void {
|
|
102
|
+
tuiMocks.matchesKey.mockImplementation((data: string, key: string) => {
|
|
103
|
+
if ((key === 'enter' || key === 'return') && data === 'enter') {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return data === key;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('AccountOverlayComponent', () => {
|
|
111
|
+
const components: AccountOverlayComponent[] = [];
|
|
112
|
+
const rollupStatus: RollupStatus = { status: 'healthy', message: '🔴 0 🟡 0 🟢 2' };
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
vi.clearAllMocks();
|
|
116
|
+
installSimpleKeyMatcher();
|
|
117
|
+
accountClientMocks.resolveCurrentKeyRelation.mockResolvedValue({
|
|
118
|
+
kind: 'unresolved',
|
|
119
|
+
reason: 'missing-current-key-match',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
for (const component of components) {
|
|
125
|
+
component.dispose();
|
|
126
|
+
}
|
|
127
|
+
components.length = 0;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not render internal hashes and advertises disable when the selected inventory row is mutable', () => {
|
|
131
|
+
const longHash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
|
|
132
|
+
const component = new AccountOverlayComponent(
|
|
133
|
+
[
|
|
134
|
+
createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 }),
|
|
135
|
+
createKey({ name: 'Automation', hash: longHash, spend: 5, label: 'sk-or-v1-999' }),
|
|
136
|
+
],
|
|
137
|
+
25,
|
|
138
|
+
rollupStatus,
|
|
139
|
+
null,
|
|
140
|
+
createIdentityTheme(),
|
|
141
|
+
() => {},
|
|
142
|
+
() => {},
|
|
143
|
+
undefined,
|
|
144
|
+
true,
|
|
145
|
+
createInventoryMatchRelation('hash-management'),
|
|
146
|
+
);
|
|
147
|
+
components.push(component);
|
|
148
|
+
|
|
149
|
+
const output = renderText(component);
|
|
150
|
+
|
|
151
|
+
expect(output).not.toContain('hash ');
|
|
152
|
+
expect(output).not.toContain('hash-primary');
|
|
153
|
+
expect(output).not.toContain(longHash);
|
|
154
|
+
expect(output).toContain('t disable');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('uses the selected key hash internally for t+enter and keeps that key selected after re-sort', async () => {
|
|
158
|
+
const updatedState: Partial<KeyInfo> = {
|
|
159
|
+
...createKey({
|
|
160
|
+
name: 'Primary',
|
|
161
|
+
hash: 'hash-primary',
|
|
162
|
+
status: 'disabled',
|
|
163
|
+
disabled: true,
|
|
164
|
+
spend: 20,
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
delete updatedState.workspaceName;
|
|
168
|
+
accountClientMocks.setApiKeyDisabled.mockResolvedValue(updatedState);
|
|
169
|
+
|
|
170
|
+
const component = new AccountOverlayComponent(
|
|
171
|
+
[
|
|
172
|
+
createKey({
|
|
173
|
+
name: 'Primary',
|
|
174
|
+
hash: 'hash-primary',
|
|
175
|
+
spend: 20,
|
|
176
|
+
workspaceName: 'Canonical Workspace',
|
|
177
|
+
}),
|
|
178
|
+
createKey({
|
|
179
|
+
name: 'Automation',
|
|
180
|
+
hash: 'hash-automation',
|
|
181
|
+
spend: 5,
|
|
182
|
+
label: 'sk-or-v1-999',
|
|
183
|
+
workspaceName: 'Other Workspace',
|
|
184
|
+
}),
|
|
185
|
+
],
|
|
186
|
+
25,
|
|
187
|
+
rollupStatus,
|
|
188
|
+
null,
|
|
189
|
+
createIdentityTheme(),
|
|
190
|
+
() => {},
|
|
191
|
+
() => {},
|
|
192
|
+
undefined,
|
|
193
|
+
true,
|
|
194
|
+
createInventoryMatchRelation('hash-management'),
|
|
195
|
+
);
|
|
196
|
+
components.push(component);
|
|
197
|
+
|
|
198
|
+
component.handleInput('t');
|
|
199
|
+
expect(renderText(component)).toContain('Press Enter to disable Primary');
|
|
200
|
+
|
|
201
|
+
component.handleInput('enter');
|
|
202
|
+
|
|
203
|
+
await vi.waitFor(() => {
|
|
204
|
+
expect(accountClientMocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-primary', true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await vi.waitFor(() => {
|
|
208
|
+
const output = renderText(component);
|
|
209
|
+
expect(output).toContain('name Primary');
|
|
210
|
+
expect(output).toContain('status disabled');
|
|
211
|
+
expect(output).toContain('status Primary disabled.');
|
|
212
|
+
expect(output).toContain('Canonical');
|
|
213
|
+
expect(output).not.toContain('hash-primary');
|
|
214
|
+
expect(output).not.toContain('hash-automation');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('renders read-only when management key capabilities are unavailable', () => {
|
|
219
|
+
const component = new AccountOverlayComponent(
|
|
220
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
221
|
+
25,
|
|
222
|
+
rollupStatus,
|
|
223
|
+
null,
|
|
224
|
+
createIdentityTheme(),
|
|
225
|
+
() => {},
|
|
226
|
+
() => {},
|
|
227
|
+
undefined,
|
|
228
|
+
false,
|
|
229
|
+
);
|
|
230
|
+
components.push(component);
|
|
231
|
+
|
|
232
|
+
expect(renderText(component)).toContain('readonly Set OPENROUTER_MANAGEMENT_KEY');
|
|
233
|
+
expect(renderText(component)).not.toContain('· t ');
|
|
234
|
+
|
|
235
|
+
component.handleInput('t');
|
|
236
|
+
|
|
237
|
+
expect(accountClientMocks.setApiKeyDisabled).not.toHaveBeenCalled();
|
|
238
|
+
expect(renderText(component)).toContain('Set OPENROUTER_MANAGEMENT_KEY');
|
|
239
|
+
expect(renderText(component)).not.toContain('hash-primary');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('blocks toggles for rows without a trusted inventory hash', () => {
|
|
243
|
+
const currentKey = createKey({ name: 'Current Key', spend: 20 });
|
|
244
|
+
delete currentKey.hash;
|
|
245
|
+
|
|
246
|
+
const component = new AccountOverlayComponent(
|
|
247
|
+
[currentKey],
|
|
248
|
+
25,
|
|
249
|
+
rollupStatus,
|
|
250
|
+
null,
|
|
251
|
+
createIdentityTheme(),
|
|
252
|
+
() => {},
|
|
253
|
+
() => {},
|
|
254
|
+
undefined,
|
|
255
|
+
true,
|
|
256
|
+
createInventoryMatchRelation('hash-management'),
|
|
257
|
+
);
|
|
258
|
+
components.push(component);
|
|
259
|
+
|
|
260
|
+
expect(renderText(component)).toContain(
|
|
261
|
+
'readonly This row is not backed by key inventory metadata.',
|
|
262
|
+
);
|
|
263
|
+
expect(renderText(component)).not.toContain('· t ');
|
|
264
|
+
|
|
265
|
+
component.handleInput('t');
|
|
266
|
+
|
|
267
|
+
expect(accountClientMocks.setApiKeyDisabled).not.toHaveBeenCalled();
|
|
268
|
+
expect(renderText(component)).toContain('This row is not backed by key inventory metadata.');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('blocks disabling keys until current management key identity exists', () => {
|
|
272
|
+
const component = new AccountOverlayComponent(
|
|
273
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
274
|
+
25,
|
|
275
|
+
rollupStatus,
|
|
276
|
+
null,
|
|
277
|
+
createIdentityTheme(),
|
|
278
|
+
() => {},
|
|
279
|
+
() => {},
|
|
280
|
+
);
|
|
281
|
+
components.push(component);
|
|
282
|
+
|
|
283
|
+
expect(renderText(component)).toContain(
|
|
284
|
+
'readonly Cannot verify current key matches this row.',
|
|
285
|
+
);
|
|
286
|
+
expect(renderText(component)).not.toContain('· t ');
|
|
287
|
+
|
|
288
|
+
component.handleInput('t');
|
|
289
|
+
|
|
290
|
+
expect(accountClientMocks.setApiKeyDisabled).not.toHaveBeenCalled();
|
|
291
|
+
expect(renderText(component)).toContain('Cannot verify current key matches this row.');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('allows disabling inventory rows when current auth is an external provisioning key', async () => {
|
|
295
|
+
accountClientMocks.setApiKeyDisabled.mockResolvedValue(
|
|
296
|
+
createKey({
|
|
297
|
+
name: 'default-space-key',
|
|
298
|
+
hash: 'hash-default-space',
|
|
299
|
+
status: 'disabled',
|
|
300
|
+
disabled: true,
|
|
301
|
+
spend: 0,
|
|
302
|
+
label: 'sk-or-v1-8ef...062',
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const component = new AccountOverlayComponent(
|
|
307
|
+
[
|
|
308
|
+
createKey({
|
|
309
|
+
name: 'default-space-key',
|
|
310
|
+
hash: 'hash-default-space',
|
|
311
|
+
label: 'sk-or-v1-8ef...062',
|
|
312
|
+
spend: 20,
|
|
313
|
+
}),
|
|
314
|
+
],
|
|
315
|
+
25,
|
|
316
|
+
rollupStatus,
|
|
317
|
+
null,
|
|
318
|
+
createIdentityTheme(),
|
|
319
|
+
() => {},
|
|
320
|
+
() => {},
|
|
321
|
+
undefined,
|
|
322
|
+
true,
|
|
323
|
+
createExternalProvisioningRelation('sk-or-v1-4a0...459'),
|
|
324
|
+
);
|
|
325
|
+
components.push(component);
|
|
326
|
+
|
|
327
|
+
expect(renderText(component)).toContain('t disable');
|
|
328
|
+
expect(renderText(component)).not.toContain(
|
|
329
|
+
'readonly Cannot verify current key matches this row.',
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
component.handleInput('t');
|
|
333
|
+
component.handleInput('enter');
|
|
334
|
+
|
|
335
|
+
await vi.waitFor(() => {
|
|
336
|
+
expect(accountClientMocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-default-space', true);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('blocks disabling rows when multiple inventory keys match the current key label', () => {
|
|
341
|
+
const component = new AccountOverlayComponent(
|
|
342
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
343
|
+
25,
|
|
344
|
+
rollupStatus,
|
|
345
|
+
null,
|
|
346
|
+
createIdentityTheme(),
|
|
347
|
+
() => {},
|
|
348
|
+
() => {},
|
|
349
|
+
undefined,
|
|
350
|
+
true,
|
|
351
|
+
{
|
|
352
|
+
kind: 'ambiguous-label',
|
|
353
|
+
label: 'sk-or-v1-123',
|
|
354
|
+
matchingHashes: ['hash-primary', 'hash-secondary'],
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
components.push(component);
|
|
358
|
+
|
|
359
|
+
expect(renderText(component)).toContain('readonly Multiple keys match the current key label.');
|
|
360
|
+
expect(renderText(component)).not.toContain('· t ');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('allows enabling disabled keys even when current management key identity is unavailable', async () => {
|
|
364
|
+
accountClientMocks.setApiKeyDisabled.mockResolvedValue(
|
|
365
|
+
createKey({
|
|
366
|
+
name: 'Disabled Key',
|
|
367
|
+
hash: 'hash-disabled',
|
|
368
|
+
status: 'healthy',
|
|
369
|
+
disabled: false,
|
|
370
|
+
spend: 5,
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const component = new AccountOverlayComponent(
|
|
375
|
+
[
|
|
376
|
+
createKey({
|
|
377
|
+
name: 'Disabled Key',
|
|
378
|
+
hash: 'hash-disabled',
|
|
379
|
+
status: 'disabled',
|
|
380
|
+
disabled: true,
|
|
381
|
+
spend: 0,
|
|
382
|
+
}),
|
|
383
|
+
],
|
|
384
|
+
25,
|
|
385
|
+
rollupStatus,
|
|
386
|
+
null,
|
|
387
|
+
createIdentityTheme(),
|
|
388
|
+
() => {},
|
|
389
|
+
() => {},
|
|
390
|
+
);
|
|
391
|
+
components.push(component);
|
|
392
|
+
|
|
393
|
+
expect(renderText(component)).toContain('t enable');
|
|
394
|
+
|
|
395
|
+
component.handleInput('t');
|
|
396
|
+
expect(renderText(component)).toContain('Press Enter to enable Disabled Key');
|
|
397
|
+
|
|
398
|
+
component.handleInput('enter');
|
|
399
|
+
|
|
400
|
+
await vi.waitFor(() => {
|
|
401
|
+
expect(accountClientMocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-disabled', false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await vi.waitFor(() => {
|
|
405
|
+
expect(renderText(component)).toContain('status Disabled Key enabled.');
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('blocks disabling the active management key when its identity is known', () => {
|
|
410
|
+
const component = new AccountOverlayComponent(
|
|
411
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
412
|
+
25,
|
|
413
|
+
rollupStatus,
|
|
414
|
+
null,
|
|
415
|
+
createIdentityTheme(),
|
|
416
|
+
() => {},
|
|
417
|
+
() => {},
|
|
418
|
+
undefined,
|
|
419
|
+
true,
|
|
420
|
+
createInventoryMatchRelation('hash-primary', 'sk-or-v1-123'),
|
|
421
|
+
);
|
|
422
|
+
components.push(component);
|
|
423
|
+
|
|
424
|
+
expect(renderText(component)).toContain('readonly Cannot disable the active management key.');
|
|
425
|
+
expect(renderText(component)).not.toContain('· t ');
|
|
426
|
+
|
|
427
|
+
component.handleInput('t');
|
|
428
|
+
|
|
429
|
+
expect(accountClientMocks.setApiKeyDisabled).not.toHaveBeenCalled();
|
|
430
|
+
expect(renderText(component)).toContain('Cannot disable the active management key.');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('blocks close shortcuts while a toggle is pending', async () => {
|
|
434
|
+
const toggle = createDeferred<Partial<KeyInfo>>();
|
|
435
|
+
accountClientMocks.setApiKeyDisabled.mockReturnValue(toggle.promise);
|
|
436
|
+
const onClose = vi.fn();
|
|
437
|
+
|
|
438
|
+
const component = new AccountOverlayComponent(
|
|
439
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
440
|
+
25,
|
|
441
|
+
rollupStatus,
|
|
442
|
+
null,
|
|
443
|
+
createIdentityTheme(),
|
|
444
|
+
onClose,
|
|
445
|
+
() => {},
|
|
446
|
+
undefined,
|
|
447
|
+
true,
|
|
448
|
+
createInventoryMatchRelation('hash-management'),
|
|
449
|
+
);
|
|
450
|
+
components.push(component);
|
|
451
|
+
|
|
452
|
+
component.handleInput('t');
|
|
453
|
+
component.handleInput('enter');
|
|
454
|
+
|
|
455
|
+
await vi.waitFor(() => {
|
|
456
|
+
expect(accountClientMocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-primary', true);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
expect(renderText(component)).toContain('status Disabling Primary...');
|
|
460
|
+
|
|
461
|
+
component.handleInput('q');
|
|
462
|
+
component.handleInput('ctrl+c');
|
|
463
|
+
component.handleInput('escape');
|
|
464
|
+
|
|
465
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
466
|
+
|
|
467
|
+
toggle.resolve({
|
|
468
|
+
name: 'Primary',
|
|
469
|
+
hash: 'hash-primary',
|
|
470
|
+
status: 'disabled',
|
|
471
|
+
disabled: true,
|
|
472
|
+
spend: 20,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
await vi.waitFor(() => {
|
|
476
|
+
expect(renderText(component)).toContain('status Primary disabled.');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
component.handleInput('q');
|
|
480
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('finalizes the toggle result even if the overlay is disposed while pending', async () => {
|
|
484
|
+
const toggle = createDeferred<Partial<KeyInfo>>();
|
|
485
|
+
accountClientMocks.setApiKeyDisabled.mockReturnValue(toggle.promise);
|
|
486
|
+
|
|
487
|
+
const component = new AccountOverlayComponent(
|
|
488
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
489
|
+
25,
|
|
490
|
+
rollupStatus,
|
|
491
|
+
null,
|
|
492
|
+
createIdentityTheme(),
|
|
493
|
+
() => {},
|
|
494
|
+
() => {},
|
|
495
|
+
undefined,
|
|
496
|
+
true,
|
|
497
|
+
createInventoryMatchRelation('hash-management'),
|
|
498
|
+
);
|
|
499
|
+
components.push(component);
|
|
500
|
+
|
|
501
|
+
component.handleInput('t');
|
|
502
|
+
component.handleInput('enter');
|
|
503
|
+
|
|
504
|
+
await vi.waitFor(() => {
|
|
505
|
+
expect(accountClientMocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-primary', true);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
component.dispose();
|
|
509
|
+
|
|
510
|
+
toggle.resolve({
|
|
511
|
+
name: 'Primary',
|
|
512
|
+
hash: 'hash-primary',
|
|
513
|
+
status: 'disabled',
|
|
514
|
+
disabled: true,
|
|
515
|
+
spend: 20,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await vi.waitFor(() => {
|
|
519
|
+
const output = renderText(component);
|
|
520
|
+
expect(output).toContain('status disabled');
|
|
521
|
+
expect(output).toContain('status Primary disabled.');
|
|
522
|
+
expect((component as any).pendingToggleHash).toBeNull();
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('does not fall back to current-key metadata on refresh when inventory is empty but manageable', async () => {
|
|
527
|
+
accountClientMocks.getAllKeys.mockResolvedValue(
|
|
528
|
+
createKeyInventory([], { canManageKeys: true }),
|
|
529
|
+
);
|
|
530
|
+
accountClientMocks.getAccountCredits.mockResolvedValue(25);
|
|
531
|
+
accountClientMocks.getCurrentKey.mockResolvedValue(
|
|
532
|
+
createKey({ name: 'Current', hash: 'hash-current' }),
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const component = new AccountOverlayComponent(
|
|
536
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
537
|
+
25,
|
|
538
|
+
rollupStatus,
|
|
539
|
+
null,
|
|
540
|
+
createIdentityTheme(),
|
|
541
|
+
() => {},
|
|
542
|
+
() => {},
|
|
543
|
+
{} as any,
|
|
544
|
+
);
|
|
545
|
+
components.push(component);
|
|
546
|
+
|
|
547
|
+
await component.refresh();
|
|
548
|
+
|
|
549
|
+
expect(accountClientMocks.resolveCurrentKeyRelation).not.toHaveBeenCalled();
|
|
550
|
+
expect(accountClientMocks.getCurrentKey).not.toHaveBeenCalled();
|
|
551
|
+
expect(renderText(component)).toContain('No keys available');
|
|
552
|
+
expect(renderText(component)).not.toContain('readonly Set OPENROUTER_MANAGEMENT_KEY');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('falls back to current-key metadata on refresh when management capability is degraded', async () => {
|
|
556
|
+
accountClientMocks.getAllKeys.mockResolvedValue(
|
|
557
|
+
createKeyInventory([], {
|
|
558
|
+
canManageKeys: false,
|
|
559
|
+
degradedReason: 'management-unavailable',
|
|
560
|
+
}),
|
|
561
|
+
);
|
|
562
|
+
accountClientMocks.getAccountCredits.mockResolvedValue(25);
|
|
563
|
+
accountClientMocks.getCurrentKey.mockResolvedValue(
|
|
564
|
+
createKey({ name: 'Current', hash: 'hash-current', spend: 12 }),
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const component = new AccountOverlayComponent(
|
|
568
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
569
|
+
25,
|
|
570
|
+
rollupStatus,
|
|
571
|
+
null,
|
|
572
|
+
createIdentityTheme(),
|
|
573
|
+
() => {},
|
|
574
|
+
() => {},
|
|
575
|
+
{} as any,
|
|
576
|
+
);
|
|
577
|
+
components.push(component);
|
|
578
|
+
|
|
579
|
+
await component.refresh();
|
|
580
|
+
|
|
581
|
+
expect(accountClientMocks.getCurrentKey).toHaveBeenCalledTimes(1);
|
|
582
|
+
expect(renderText(component)).toContain('name Current');
|
|
583
|
+
expect(renderText(component)).toContain('readonly Set OPENROUTER_MANAGEMENT_KEY');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it.each([400, 404])(
|
|
587
|
+
'maps invalid or missing selected-key toggle errors (status %s) to refresh guidance without rendering internal hashes',
|
|
588
|
+
async (statusCode) => {
|
|
589
|
+
accountClientMocks.setApiKeyDisabled.mockRejectedValue(
|
|
590
|
+
createApiError(
|
|
591
|
+
statusCode === 400 ? 'OpenRouter rejected hash-primary as invalid' : 'Key not found',
|
|
592
|
+
statusCode,
|
|
593
|
+
),
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const component = new AccountOverlayComponent(
|
|
597
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
598
|
+
25,
|
|
599
|
+
rollupStatus,
|
|
600
|
+
null,
|
|
601
|
+
createIdentityTheme(),
|
|
602
|
+
() => {},
|
|
603
|
+
() => {},
|
|
604
|
+
undefined,
|
|
605
|
+
true,
|
|
606
|
+
createInventoryMatchRelation('hash-management'),
|
|
607
|
+
);
|
|
608
|
+
components.push(component);
|
|
609
|
+
|
|
610
|
+
component.handleInput('t');
|
|
611
|
+
component.handleInput('enter');
|
|
612
|
+
|
|
613
|
+
await vi.waitFor(() => {
|
|
614
|
+
expect(accountClientMocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-primary', true);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
await vi.waitFor(() => {
|
|
618
|
+
expect((component as any).inlineMessage).toBe(
|
|
619
|
+
'Failed to disable Primary: OpenRouter could not match the selected key. Refresh the account view and try again.',
|
|
620
|
+
);
|
|
621
|
+
const output = renderText(component);
|
|
622
|
+
expect(output).not.toContain('hash-primary');
|
|
623
|
+
expect(output).not.toContain('rejected hash-primary');
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
it.each([
|
|
629
|
+
{
|
|
630
|
+
title: 'management-key auth errors',
|
|
631
|
+
error: createAuthError('Unauthorized'),
|
|
632
|
+
expected:
|
|
633
|
+
'Failed to disable Primary: Set OPENROUTER_MANAGEMENT_KEY to a valid management key, then refresh and try again.',
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
title: 'management-key permission errors',
|
|
637
|
+
error: createApiError('Forbidden', 403),
|
|
638
|
+
expected:
|
|
639
|
+
'Failed to disable Primary: OPENROUTER_MANAGEMENT_KEY does not have permission to update keys. Set it to a valid management key and refresh.',
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
title: 'retryable rate-limit errors',
|
|
643
|
+
error: createApiError('Too Many Requests', 429),
|
|
644
|
+
expected:
|
|
645
|
+
'Failed to disable Primary: OpenRouter could not update the selected key right now. Retry in a moment and refresh.',
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
title: 'retryable service errors',
|
|
649
|
+
error: createApiError('Service unavailable', 503),
|
|
650
|
+
expected:
|
|
651
|
+
'Failed to disable Primary: OpenRouter could not update the selected key right now. Retry in a moment and refresh.',
|
|
652
|
+
},
|
|
653
|
+
])('maps $title to distinct inline guidance', async ({ error, expected }) => {
|
|
654
|
+
accountClientMocks.setApiKeyDisabled.mockRejectedValue(error);
|
|
655
|
+
|
|
656
|
+
const component = new AccountOverlayComponent(
|
|
657
|
+
[createKey({ name: 'Primary', hash: 'hash-primary', spend: 20 })],
|
|
658
|
+
25,
|
|
659
|
+
rollupStatus,
|
|
660
|
+
null,
|
|
661
|
+
createIdentityTheme(),
|
|
662
|
+
() => {},
|
|
663
|
+
() => {},
|
|
664
|
+
undefined,
|
|
665
|
+
true,
|
|
666
|
+
createInventoryMatchRelation('hash-management'),
|
|
667
|
+
);
|
|
668
|
+
components.push(component);
|
|
669
|
+
|
|
670
|
+
component.handleInput('t');
|
|
671
|
+
component.handleInput('enter');
|
|
672
|
+
|
|
673
|
+
await vi.waitFor(() => {
|
|
674
|
+
expect(accountClientMocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-primary', true);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
await vi.waitFor(() => {
|
|
678
|
+
expect((component as any).inlineMessage).toBe(expected);
|
|
679
|
+
const output = renderText(component);
|
|
680
|
+
expect(output).not.toContain('hash-primary');
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
});
|