@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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
createApiKey: vi.fn(),
|
|
5
|
+
setApiKeyDisabled: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../account-client.js', () => ({
|
|
9
|
+
createApiKey: mocks.createApiKey,
|
|
10
|
+
setApiKeyDisabled: mocks.setApiKeyDisabled,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
handleApiKeyCreate,
|
|
15
|
+
handleApiKeyDisable,
|
|
16
|
+
handleApiKeyEnable,
|
|
17
|
+
isUtcIsoTimestamp,
|
|
18
|
+
parseApiKeyCreateArgs,
|
|
19
|
+
} from '../api-key-commands.js';
|
|
20
|
+
|
|
21
|
+
describe('api-key-commands', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.resetAllMocks();
|
|
24
|
+
mocks.createApiKey.mockResolvedValue({
|
|
25
|
+
key: 'sk-or-v1-secret',
|
|
26
|
+
keyState: {
|
|
27
|
+
name: 'Team Key',
|
|
28
|
+
hash: 'hash-123',
|
|
29
|
+
disabled: false,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
mocks.setApiKeyDisabled.mockImplementation(async (hash: string, disabled: boolean) => ({
|
|
33
|
+
name: disabled ? 'Disabled Key' : 'Enabled Key',
|
|
34
|
+
hash,
|
|
35
|
+
disabled,
|
|
36
|
+
}));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('isUtcIsoTimestamp', () => {
|
|
40
|
+
it('accepts canonical UTC ISO timestamps with or without milliseconds', () => {
|
|
41
|
+
expect(isUtcIsoTimestamp('2026-06-01T00:00:00Z')).toBe(true);
|
|
42
|
+
expect(isUtcIsoTimestamp('2026-06-01T00:00:00.000Z')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects non-UTC or malformed timestamps', () => {
|
|
46
|
+
expect(isUtcIsoTimestamp('2026-06-01T00:00:00+00:00')).toBe(false);
|
|
47
|
+
expect(isUtcIsoTimestamp('2026-06-01')).toBe(false);
|
|
48
|
+
expect(isUtcIsoTimestamp('wat')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('parseApiKeyCreateArgs', () => {
|
|
53
|
+
it('parses create args and maps none/incl fields to SDK-friendly values', () => {
|
|
54
|
+
const parsed = parseApiKeyCreateArgs(
|
|
55
|
+
'team limit=none reset=weekly byok=incl workspace=ws-1 expires=2026-06-01T00:00:00Z',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(parsed).toEqual({
|
|
59
|
+
ok: true,
|
|
60
|
+
value: {
|
|
61
|
+
name: 'team',
|
|
62
|
+
limit: null,
|
|
63
|
+
limitReset: 'weekly',
|
|
64
|
+
includeByokInLimit: true,
|
|
65
|
+
workspaceId: 'ws-1',
|
|
66
|
+
expiresAt: new Date('2026-06-01T00:00:00Z'),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('supports quoted key names with spaces', () => {
|
|
72
|
+
const parsed = parseApiKeyCreateArgs('"Team Key" limit=10');
|
|
73
|
+
|
|
74
|
+
expect(parsed).toEqual({
|
|
75
|
+
ok: true,
|
|
76
|
+
value: {
|
|
77
|
+
name: 'Team Key',
|
|
78
|
+
limit: 10,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects option-like first tokens because name is required', () => {
|
|
84
|
+
const parsed = parseApiKeyCreateArgs('limit=25 reset=weekly');
|
|
85
|
+
|
|
86
|
+
expect(parsed).toEqual({
|
|
87
|
+
ok: false,
|
|
88
|
+
message:
|
|
89
|
+
'Usage: /openrouter api-key-create <name> [limit=<usd|none>] [reset=<daily|weekly|monthly|none>] [byok=<incl|excl>] [workspace=<id>] [expires=<UTC ISO>]',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects invalid numeric limits', () => {
|
|
94
|
+
const parsed = parseApiKeyCreateArgs('team limit=abc');
|
|
95
|
+
|
|
96
|
+
expect(parsed).toEqual({
|
|
97
|
+
ok: false,
|
|
98
|
+
message: 'Invalid limit value: "abc"\nExpected a non-negative USD amount or \'none\'.',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects invalid reset values', () => {
|
|
103
|
+
const parsed = parseApiKeyCreateArgs('team reset=hourly');
|
|
104
|
+
|
|
105
|
+
expect(parsed).toEqual({
|
|
106
|
+
ok: false,
|
|
107
|
+
message: 'Invalid reset value: "hourly"\nAllowed values: daily, weekly, monthly, none.',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects invalid byok values', () => {
|
|
112
|
+
const parsed = parseApiKeyCreateArgs('team byok=maybe');
|
|
113
|
+
|
|
114
|
+
expect(parsed).toEqual({
|
|
115
|
+
ok: false,
|
|
116
|
+
message: 'Invalid byok value: "maybe"\nAllowed values: incl, excl.',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects non-UTC expiry values', () => {
|
|
121
|
+
const parsed = parseApiKeyCreateArgs('team expires=2026-06-01T00:00:00+00:00');
|
|
122
|
+
|
|
123
|
+
expect(parsed).toEqual({
|
|
124
|
+
ok: false,
|
|
125
|
+
message:
|
|
126
|
+
'Invalid expires value: "2026-06-01T00:00:00+00:00"\nExpected an ISO 8601 UTC timestamp ending in Z, for example 2026-06-01T00:00:00Z.',
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('handleApiKeyCreate', () => {
|
|
132
|
+
it('calls createApiKey with parsed args and returns the secret out-of-band exactly once', async () => {
|
|
133
|
+
const result = await handleApiKeyCreate(
|
|
134
|
+
'team limit=25.5 reset=none byok=excl workspace=ws-9 expires=2026-06-01T00:00:00Z',
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(mocks.createApiKey).toHaveBeenCalledWith({
|
|
138
|
+
name: 'team',
|
|
139
|
+
limit: 25.5,
|
|
140
|
+
limitReset: null,
|
|
141
|
+
includeByokInLimit: false,
|
|
142
|
+
workspaceId: 'ws-9',
|
|
143
|
+
expiresAt: new Date('2026-06-01T00:00:00Z'),
|
|
144
|
+
});
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
expect(result.message).toContain('OpenRouter API key created');
|
|
147
|
+
expect(result.message).toContain('Use /openrouter account to inspect or toggle the key.');
|
|
148
|
+
expect(result.message).toContain('Secret shown in secure overlay; store it now.');
|
|
149
|
+
expect(result.message).toContain('Warning: This secret cannot be recovered');
|
|
150
|
+
expect(result.message).not.toContain('sk-or-v1-secret');
|
|
151
|
+
expect(result.message).not.toContain('hash-123');
|
|
152
|
+
expect(result.secret).toBe('sk-or-v1-secret');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns validation failures before calling account-client helpers', async () => {
|
|
156
|
+
const result = await handleApiKeyCreate('team limit=-1');
|
|
157
|
+
|
|
158
|
+
expect(mocks.createApiKey).not.toHaveBeenCalled();
|
|
159
|
+
expect(result).toEqual({
|
|
160
|
+
success: false,
|
|
161
|
+
message: 'Invalid limit value: "-1"\nExpected a non-negative USD amount or \'none\'.',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('surfaces helper errors as failure messages', async () => {
|
|
166
|
+
mocks.createApiKey.mockRejectedValue(
|
|
167
|
+
new Error('Management key required to create API keys.'),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const result = await handleApiKeyCreate('team');
|
|
171
|
+
|
|
172
|
+
expect(result).toEqual({
|
|
173
|
+
success: false,
|
|
174
|
+
message: 'Management key required to create API keys.',
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('handleApiKeyDisable / handleApiKeyEnable', () => {
|
|
180
|
+
it('disables a key by hash without echoing the hash in the success message', async () => {
|
|
181
|
+
const result = await handleApiKeyDisable('hash-disable');
|
|
182
|
+
|
|
183
|
+
expect(mocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-disable', true);
|
|
184
|
+
expect(result).toEqual({
|
|
185
|
+
success: true,
|
|
186
|
+
message:
|
|
187
|
+
'OpenRouter API key disabled\nName: Disabled Key\nStatus: disabled\nRun /openrouter account to verify.',
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('enables a key by hash without echoing the hash in the success message', async () => {
|
|
192
|
+
const result = await handleApiKeyEnable('hash-enable');
|
|
193
|
+
|
|
194
|
+
expect(mocks.setApiKeyDisabled).toHaveBeenCalledWith('hash-enable', false);
|
|
195
|
+
expect(result).toEqual({
|
|
196
|
+
success: true,
|
|
197
|
+
message:
|
|
198
|
+
'OpenRouter API key enabled\nName: Enabled Key\nStatus: enabled\nRun /openrouter account to verify.',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('sanitizes helper errors that might include hashes', async () => {
|
|
203
|
+
mocks.setApiKeyDisabled.mockRejectedValue(new Error('OpenRouter rejected hash-disable'));
|
|
204
|
+
|
|
205
|
+
const result = await handleApiKeyDisable('hash-disable');
|
|
206
|
+
|
|
207
|
+
expect(result).toEqual({
|
|
208
|
+
success: false,
|
|
209
|
+
message:
|
|
210
|
+
'Failed to disable OpenRouter API key. Check the key identifier and management-key permissions.',
|
|
211
|
+
});
|
|
212
|
+
expect(result.message).not.toContain('hash-disable');
|
|
213
|
+
expect(result.message).not.toContain('rejected');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('requires exactly one hash argument', async () => {
|
|
217
|
+
const result = await handleApiKeyDisable('');
|
|
218
|
+
const resultWithExtra = await handleApiKeyEnable('hash extra');
|
|
219
|
+
|
|
220
|
+
expect(mocks.setApiKeyDisabled).not.toHaveBeenCalled();
|
|
221
|
+
expect(result).toEqual({
|
|
222
|
+
success: false,
|
|
223
|
+
message: 'Usage: /openrouter api-key-disable <hash>',
|
|
224
|
+
});
|
|
225
|
+
expect(resultWithExtra).toEqual({
|
|
226
|
+
success: false,
|
|
227
|
+
message: 'Usage: /openrouter api-key-enable <hash>',
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -37,8 +37,15 @@ const { mocks, overlayConstructorCalls, MockUsageOverlayComponent } = vi.hoisted
|
|
|
37
37
|
getCurrentSessionId: vi.fn(),
|
|
38
38
|
getAllKeys: vi.fn(),
|
|
39
39
|
getCurrentKey: vi.fn(),
|
|
40
|
+
resolveCurrentKeyRelation: vi.fn(),
|
|
40
41
|
getAccountCredits: vi.fn(),
|
|
41
42
|
computeRollupStatus: vi.fn(),
|
|
43
|
+
formatCurrency: vi.fn((amount: number) => `$${amount.toFixed(2)}`),
|
|
44
|
+
formatRemaining: vi.fn((used: number, limit?: number) =>
|
|
45
|
+
limit === undefined
|
|
46
|
+
? `$${used.toFixed(2)} / unlimited`
|
|
47
|
+
: `$${used.toFixed(2)} / $${limit.toFixed(2)}`,
|
|
48
|
+
),
|
|
42
49
|
sortKeys: vi.fn(),
|
|
43
50
|
syncModels: vi.fn(),
|
|
44
51
|
getSyncState: vi.fn(),
|
|
@@ -52,6 +59,9 @@ const { mocks, overlayConstructorCalls, MockUsageOverlayComponent } = vi.hoisted
|
|
|
52
59
|
handleModelOverrideSet: vi.fn(),
|
|
53
60
|
handleModelOverrideClear: vi.fn(),
|
|
54
61
|
handleModelOverrideList: vi.fn(),
|
|
62
|
+
handleApiKeyCreate: vi.fn(),
|
|
63
|
+
handleApiKeyDisable: vi.fn(),
|
|
64
|
+
handleApiKeyEnable: vi.fn(),
|
|
55
65
|
},
|
|
56
66
|
};
|
|
57
67
|
});
|
|
@@ -83,11 +93,14 @@ vi.mock('../hooks.js', () => ({
|
|
|
83
93
|
vi.mock('../account-client.js', () => ({
|
|
84
94
|
getAllKeys: mocks.getAllKeys,
|
|
85
95
|
getCurrentKey: mocks.getCurrentKey,
|
|
96
|
+
resolveCurrentKeyRelation: mocks.resolveCurrentKeyRelation,
|
|
86
97
|
getAccountCredits: mocks.getAccountCredits,
|
|
87
98
|
}));
|
|
88
99
|
|
|
89
100
|
vi.mock('../account-format.js', () => ({
|
|
90
101
|
computeRollupStatus: mocks.computeRollupStatus,
|
|
102
|
+
formatCurrency: mocks.formatCurrency,
|
|
103
|
+
formatRemaining: mocks.formatRemaining,
|
|
91
104
|
sortKeys: mocks.sortKeys,
|
|
92
105
|
}));
|
|
93
106
|
|
|
@@ -115,6 +128,12 @@ vi.mock('../models/override-commands.js', () => ({
|
|
|
115
128
|
handleModelOverrideList: mocks.handleModelOverrideList,
|
|
116
129
|
}));
|
|
117
130
|
|
|
131
|
+
vi.mock('../api-key-commands.js', () => ({
|
|
132
|
+
handleApiKeyCreate: mocks.handleApiKeyCreate,
|
|
133
|
+
handleApiKeyDisable: mocks.handleApiKeyDisable,
|
|
134
|
+
handleApiKeyEnable: mocks.handleApiKeyEnable,
|
|
135
|
+
}));
|
|
136
|
+
|
|
118
137
|
vi.mock('../overlay.js', () => ({
|
|
119
138
|
UsageOverlayComponent: MockUsageOverlayComponent,
|
|
120
139
|
}));
|
|
@@ -180,6 +199,17 @@ const keyInfo = {
|
|
|
180
199
|
spend: 10,
|
|
181
200
|
} as const;
|
|
182
201
|
|
|
202
|
+
function createKeyInventory(
|
|
203
|
+
keys: any[] = [keyInfo],
|
|
204
|
+
options: { canManageKeys?: boolean; degradedReason?: string } = {},
|
|
205
|
+
) {
|
|
206
|
+
return {
|
|
207
|
+
keys,
|
|
208
|
+
canManageKeys: options.canManageKeys ?? true,
|
|
209
|
+
...(options.degradedReason ? { degradedReason: options.degradedReason } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
183
213
|
describe('registerOpenRouterCommands', () => {
|
|
184
214
|
beforeEach(() => {
|
|
185
215
|
vi.resetAllMocks();
|
|
@@ -195,8 +225,13 @@ describe('registerOpenRouterCommands', () => {
|
|
|
195
225
|
message.includes('429') || message.includes('rate limit') || message.includes('rate-limit')
|
|
196
226
|
);
|
|
197
227
|
});
|
|
198
|
-
mocks.getAllKeys.mockResolvedValue(
|
|
228
|
+
mocks.getAllKeys.mockResolvedValue(createKeyInventory());
|
|
199
229
|
mocks.getCurrentKey.mockResolvedValue(keyInfo);
|
|
230
|
+
mocks.resolveCurrentKeyRelation.mockResolvedValue({
|
|
231
|
+
kind: 'inventory-match',
|
|
232
|
+
hash: keyInfo.hash,
|
|
233
|
+
label: keyInfo.label,
|
|
234
|
+
});
|
|
200
235
|
mocks.getAccountCredits.mockResolvedValue(25);
|
|
201
236
|
mocks.computeRollupStatus.mockReturnValue({ status: 'healthy', message: 'healthy' });
|
|
202
237
|
mocks.sortKeys.mockImplementation((keys) => keys);
|
|
@@ -215,6 +250,13 @@ describe('registerOpenRouterCommands', () => {
|
|
|
215
250
|
message: 'override cleared',
|
|
216
251
|
});
|
|
217
252
|
mocks.handleModelOverrideList.mockResolvedValue('override list');
|
|
253
|
+
mocks.handleApiKeyCreate.mockResolvedValue({
|
|
254
|
+
success: true,
|
|
255
|
+
message: 'api key created\nSecret shown in secure overlay; store it now.',
|
|
256
|
+
secret: 'sk-or-v1-created-secret',
|
|
257
|
+
});
|
|
258
|
+
mocks.handleApiKeyDisable.mockResolvedValue({ success: true, message: 'api key disabled' });
|
|
259
|
+
mocks.handleApiKeyEnable.mockResolvedValue({ success: true, message: 'api key enabled' });
|
|
218
260
|
});
|
|
219
261
|
|
|
220
262
|
it('registers the expected command names and descriptions', () => {
|
|
@@ -238,11 +280,11 @@ describe('registerOpenRouterCommands', () => {
|
|
|
238
280
|
'Show OpenRouter account and key health',
|
|
239
281
|
);
|
|
240
282
|
expect(commands.get('openrouter')?.description).toBe(
|
|
241
|
-
|
|
283
|
+
`OpenRouter commands: ${OPENROUTER_SUBCOMMANDS.join(', ')}`,
|
|
242
284
|
);
|
|
243
285
|
});
|
|
244
286
|
|
|
245
|
-
it('
|
|
287
|
+
it('hides hash toggle subcommands from public /openrouter completions', () => {
|
|
246
288
|
const { commands, pi } = createMockPi();
|
|
247
289
|
|
|
248
290
|
registerOpenRouterCommands(pi as any);
|
|
@@ -253,6 +295,9 @@ describe('registerOpenRouterCommands', () => {
|
|
|
253
295
|
{ value: 'model-override-clear', label: 'model-override-clear' },
|
|
254
296
|
{ value: 'model-override-list', label: 'model-override-list' },
|
|
255
297
|
]);
|
|
298
|
+
expect(command.getArgumentCompletions('api-key-')).toEqual([
|
|
299
|
+
{ value: 'api-key-create', label: 'api-key-create' },
|
|
300
|
+
]);
|
|
256
301
|
expect(command.getArgumentCompletions('zzz')).toBeNull();
|
|
257
302
|
expect(OPENROUTER_SUBCOMMANDS).toEqual([
|
|
258
303
|
'usage',
|
|
@@ -263,6 +308,7 @@ describe('registerOpenRouterCommands', () => {
|
|
|
263
308
|
'model-override-set',
|
|
264
309
|
'model-override-clear',
|
|
265
310
|
'model-override-list',
|
|
311
|
+
'api-key-create',
|
|
266
312
|
]);
|
|
267
313
|
});
|
|
268
314
|
|
|
@@ -288,8 +334,129 @@ describe('registerOpenRouterCommands', () => {
|
|
|
288
334
|
await commands.get('openrouter').handler('account', ctx);
|
|
289
335
|
|
|
290
336
|
expect(mocks.getAllKeys).toHaveBeenCalledTimes(1);
|
|
337
|
+
expect(mocks.resolveCurrentKeyRelation).toHaveBeenCalledWith([keyInfo]);
|
|
338
|
+
expect(mocks.getCurrentKey).not.toHaveBeenCalled();
|
|
339
|
+
expect(mocks.getAccountCredits).toHaveBeenCalledTimes(1);
|
|
340
|
+
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('passes an inventory-match relation into the overlay disable guard', async () => {
|
|
344
|
+
const { commands, pi } = createMockPi();
|
|
345
|
+
const ctx = createMockContext();
|
|
346
|
+
|
|
347
|
+
const activeKey = {
|
|
348
|
+
...keyInfo,
|
|
349
|
+
name: 'default-space-key',
|
|
350
|
+
label: 'sk-or-v1-8ef...062',
|
|
351
|
+
hash: 'hash-default-space',
|
|
352
|
+
workspaceName: 'Default',
|
|
353
|
+
};
|
|
354
|
+
mocks.getAllKeys.mockResolvedValue(createKeyInventory([activeKey]));
|
|
355
|
+
mocks.resolveCurrentKeyRelation.mockResolvedValue({
|
|
356
|
+
kind: 'inventory-match',
|
|
357
|
+
hash: 'hash-default-space',
|
|
358
|
+
label: 'sk-or-v1-8ef...062',
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
registerOpenRouterCommands(pi as any);
|
|
362
|
+
await commands.get('openrouter').handler('account', ctx);
|
|
363
|
+
|
|
364
|
+
const overlayFactory = ctx.ui.custom.mock.calls[0]![0];
|
|
365
|
+
const overlay = overlayFactory(
|
|
366
|
+
{ requestRender: vi.fn() },
|
|
367
|
+
{ bold: (text: string) => text, fg: (_style: string, text: string) => text },
|
|
368
|
+
{},
|
|
369
|
+
vi.fn(),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
expect(overlay.render(120).join('\n')).toContain(
|
|
373
|
+
'readonly Cannot disable the active management key.',
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
overlay.dispose();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('allows disabling inventory rows when current auth is an external provisioning key', async () => {
|
|
380
|
+
const { commands, pi } = createMockPi();
|
|
381
|
+
const ctx = createMockContext();
|
|
382
|
+
|
|
383
|
+
const inventoryKey = {
|
|
384
|
+
...keyInfo,
|
|
385
|
+
name: 'default-space-key',
|
|
386
|
+
label: 'sk-or-v1-8ef...062',
|
|
387
|
+
hash: 'hash-default-space',
|
|
388
|
+
workspaceName: 'Default',
|
|
389
|
+
};
|
|
390
|
+
mocks.getAllKeys.mockResolvedValue(createKeyInventory([inventoryKey]));
|
|
391
|
+
mocks.resolveCurrentKeyRelation.mockResolvedValue({
|
|
392
|
+
kind: 'external-provisioning',
|
|
393
|
+
label: 'sk-or-v1-4a0...459',
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
registerOpenRouterCommands(pi as any);
|
|
397
|
+
await commands.get('openrouter').handler('account', ctx);
|
|
398
|
+
|
|
399
|
+
const overlayFactory = ctx.ui.custom.mock.calls[0]![0];
|
|
400
|
+
const overlay = overlayFactory(
|
|
401
|
+
{ requestRender: vi.fn() },
|
|
402
|
+
{ bold: (text: string) => text, fg: (_style: string, text: string) => text },
|
|
403
|
+
{},
|
|
404
|
+
vi.fn(),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
expect(overlay.render(120).join('\n')).toContain('t disable');
|
|
408
|
+
expect(overlay.render(120).join('\n')).not.toContain(
|
|
409
|
+
'readonly Cannot verify current key matches this row.',
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
overlay.dispose();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('keeps empty key inventory distinct from management-capability fallback in the command flow', async () => {
|
|
416
|
+
const { commands, pi } = createMockPi();
|
|
417
|
+
const ctx = createMockContext();
|
|
418
|
+
|
|
419
|
+
mocks.getAllKeys.mockResolvedValue(createKeyInventory([], { canManageKeys: true }));
|
|
420
|
+
|
|
421
|
+
registerOpenRouterCommands(pi as any);
|
|
422
|
+
await commands.get('openrouter').handler('account', ctx);
|
|
423
|
+
|
|
424
|
+
expect(mocks.getCurrentKey).not.toHaveBeenCalled();
|
|
291
425
|
expect(mocks.getAccountCredits).toHaveBeenCalledTimes(1);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('routes /openrouter account through the readonly fallback when management inventory is unavailable', async () => {
|
|
429
|
+
const { commands, pi } = createMockPi();
|
|
430
|
+
const ctx = createMockContext();
|
|
431
|
+
|
|
432
|
+
mocks.getAllKeys.mockResolvedValue(
|
|
433
|
+
createKeyInventory([], {
|
|
434
|
+
canManageKeys: false,
|
|
435
|
+
degradedReason: 'management-unavailable',
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
mocks.getCurrentKey.mockResolvedValue({ ...keyInfo, hash: undefined } as any);
|
|
439
|
+
|
|
440
|
+
registerOpenRouterCommands(pi as any);
|
|
441
|
+
await commands.get('openrouter').handler('account', ctx);
|
|
442
|
+
|
|
443
|
+
expect(mocks.getCurrentKey).toHaveBeenCalled();
|
|
292
444
|
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
445
|
+
|
|
446
|
+
const overlayFactory = ctx.ui.custom.mock.calls[0]![0];
|
|
447
|
+
const overlay = overlayFactory(
|
|
448
|
+
{ requestRender: vi.fn() },
|
|
449
|
+
{ bold: (text: string) => text, fg: (_style: string, text: string) => text },
|
|
450
|
+
{},
|
|
451
|
+
vi.fn(),
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
expect(overlay.render(120).join('\n')).toContain(
|
|
455
|
+
'readonly Set OPENROUTER_MANAGEMENT_KEY to toggle keys.',
|
|
456
|
+
);
|
|
457
|
+
expect(overlay.render(120).join('\n')).not.toContain('· t ');
|
|
458
|
+
|
|
459
|
+
overlay.dispose();
|
|
293
460
|
});
|
|
294
461
|
|
|
295
462
|
it('routes /openrouter session to the current session notifier', async () => {
|
|
@@ -420,6 +587,61 @@ describe('registerOpenRouterCommands', () => {
|
|
|
420
587
|
expect(ctx.ui.notify).toHaveBeenCalledWith('override list', 'info');
|
|
421
588
|
});
|
|
422
589
|
|
|
590
|
+
it('routes api-key-create through the dedicated handler, secure overlay, and redacted notifier', async () => {
|
|
591
|
+
const { commands, pi } = createMockPi();
|
|
592
|
+
const ctx = createMockContext();
|
|
593
|
+
|
|
594
|
+
registerOpenRouterCommands(pi as any);
|
|
595
|
+
await commands
|
|
596
|
+
.get('openrouter')
|
|
597
|
+
.handler('api-key-create team limit=25 reset=monthly byok=incl', ctx);
|
|
598
|
+
|
|
599
|
+
expect(mocks.handleApiKeyCreate).toHaveBeenCalledWith('team limit=25 reset=monthly byok=incl');
|
|
600
|
+
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
601
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
602
|
+
'api key created\nSecret shown in secure overlay; store it now.',
|
|
603
|
+
'info',
|
|
604
|
+
);
|
|
605
|
+
expect(ctx.ui.notify).not.toHaveBeenCalledWith(
|
|
606
|
+
expect.stringContaining('sk-or-v1-created-secret'),
|
|
607
|
+
expect.anything(),
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
const overlayFactory = ctx.ui.custom.mock.calls[0]![0];
|
|
611
|
+
const overlay = overlayFactory(
|
|
612
|
+
{ requestRender: vi.fn() },
|
|
613
|
+
{ bold: (text: string) => text, fg: (_style: string, text: string) => text },
|
|
614
|
+
{},
|
|
615
|
+
vi.fn(),
|
|
616
|
+
);
|
|
617
|
+
const rendered = overlay.render(160).join('\n');
|
|
618
|
+
expect(rendered.split('sk-or-v1-created-secret')).toHaveLength(2);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('keeps hidden api-key-disable routing through the error notifier', async () => {
|
|
622
|
+
const { commands, pi } = createMockPi();
|
|
623
|
+
const ctx = createMockContext();
|
|
624
|
+
|
|
625
|
+
mocks.handleApiKeyDisable.mockResolvedValue({ success: false, message: 'disable failed' });
|
|
626
|
+
|
|
627
|
+
registerOpenRouterCommands(pi as any);
|
|
628
|
+
await commands.get('openrouter').handler('api-key-disable hash-123', ctx);
|
|
629
|
+
|
|
630
|
+
expect(mocks.handleApiKeyDisable).toHaveBeenCalledWith('hash-123');
|
|
631
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith('disable failed', 'error');
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('keeps hidden api-key-enable routing through the info notifier', async () => {
|
|
635
|
+
const { commands, pi } = createMockPi();
|
|
636
|
+
const ctx = createMockContext();
|
|
637
|
+
|
|
638
|
+
registerOpenRouterCommands(pi as any);
|
|
639
|
+
await commands.get('openrouter').handler('api-key-enable hash-123', ctx);
|
|
640
|
+
|
|
641
|
+
expect(mocks.handleApiKeyEnable).toHaveBeenCalledWith('hash-123');
|
|
642
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith('api key enabled', 'info');
|
|
643
|
+
});
|
|
644
|
+
|
|
423
645
|
it('keeps unknown-subcommand messaging unchanged', async () => {
|
|
424
646
|
const { commands, pi } = createMockPi();
|
|
425
647
|
const ctx = createMockContext();
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
CreateKeysData,
|
|
5
|
+
GetCurrentKeyData,
|
|
6
|
+
ListData,
|
|
7
|
+
UpdateKeysData,
|
|
8
|
+
} from '@openrouter/sdk/models/operations/index.js';
|
|
4
9
|
import {
|
|
5
10
|
normalizeOpenRouterModel,
|
|
6
11
|
normalizeSdkKeyMetadata,
|
|
@@ -89,6 +94,58 @@ function createListKeyData(overrides: Partial<ListData> = {}): ListData {
|
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
function createCreateKeysData(overrides: Partial<CreateKeysData> = {}): CreateKeysData {
|
|
98
|
+
return {
|
|
99
|
+
byokUsage: 0,
|
|
100
|
+
byokUsageDaily: 0,
|
|
101
|
+
byokUsageMonthly: 0,
|
|
102
|
+
byokUsageWeekly: 0,
|
|
103
|
+
createdAt: '2026-05-22T00:00:00.000Z',
|
|
104
|
+
creatorUserId: null,
|
|
105
|
+
disabled: false,
|
|
106
|
+
hash: 'hash-create',
|
|
107
|
+
includeByokInLimit: true,
|
|
108
|
+
label: 'sk-or-v1-create',
|
|
109
|
+
limit: 100,
|
|
110
|
+
limitRemaining: 40,
|
|
111
|
+
limitReset: 'weekly',
|
|
112
|
+
name: 'Created Key',
|
|
113
|
+
updatedAt: null,
|
|
114
|
+
usage: 60,
|
|
115
|
+
usageDaily: 0,
|
|
116
|
+
usageMonthly: 0,
|
|
117
|
+
usageWeekly: 0,
|
|
118
|
+
workspaceId: 'ws-create',
|
|
119
|
+
...overrides,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createUpdateKeysData(overrides: Partial<UpdateKeysData> = {}): UpdateKeysData {
|
|
124
|
+
return {
|
|
125
|
+
byokUsage: 0,
|
|
126
|
+
byokUsageDaily: 0,
|
|
127
|
+
byokUsageMonthly: 0,
|
|
128
|
+
byokUsageWeekly: 0,
|
|
129
|
+
createdAt: '2026-05-22T00:00:00.000Z',
|
|
130
|
+
creatorUserId: null,
|
|
131
|
+
disabled: false,
|
|
132
|
+
hash: 'hash-update',
|
|
133
|
+
includeByokInLimit: false,
|
|
134
|
+
label: 'sk-or-v1-update',
|
|
135
|
+
limit: 100,
|
|
136
|
+
limitRemaining: 40,
|
|
137
|
+
limitReset: null,
|
|
138
|
+
name: 'Updated Key',
|
|
139
|
+
updatedAt: null,
|
|
140
|
+
usage: 60,
|
|
141
|
+
usageDaily: 0,
|
|
142
|
+
usageMonthly: 0,
|
|
143
|
+
usageWeekly: 0,
|
|
144
|
+
workspaceId: 'ws-update',
|
|
145
|
+
...overrides,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
92
149
|
describe('sdkModelToOpenRouterModel', () => {
|
|
93
150
|
it('normalizes SDK camelCase fields into canonical snake_case model shape', () => {
|
|
94
151
|
const normalized = sdkModelToOpenRouterModel(
|
|
@@ -237,9 +294,9 @@ describe('normalizeSdkKeyMetadata', () => {
|
|
|
237
294
|
remaining: 0,
|
|
238
295
|
byok: 'excl',
|
|
239
296
|
resetCadence: 'daily',
|
|
240
|
-
hash: 'unknown',
|
|
241
297
|
disabled: false,
|
|
242
298
|
});
|
|
299
|
+
expect(normalized).not.toHaveProperty('hash');
|
|
243
300
|
expect('limit' in normalized).toBe(true);
|
|
244
301
|
expect('remaining' in normalized).toBe(true);
|
|
245
302
|
});
|
|
@@ -258,9 +315,9 @@ describe('normalizeSdkKeyMetadata', () => {
|
|
|
258
315
|
name: 'sk-or-v1-current',
|
|
259
316
|
byok: '?',
|
|
260
317
|
resetCadence: 'partial',
|
|
261
|
-
hash: 'unknown',
|
|
262
318
|
disabled: false,
|
|
263
319
|
});
|
|
320
|
+
expect(normalized).not.toHaveProperty('hash');
|
|
264
321
|
expect(normalized).not.toHaveProperty('limit');
|
|
265
322
|
expect(normalized).not.toHaveProperty('remaining');
|
|
266
323
|
});
|
|
@@ -285,4 +342,30 @@ describe('normalizeSdkKeyMetadata', () => {
|
|
|
285
342
|
remaining: 40,
|
|
286
343
|
});
|
|
287
344
|
});
|
|
345
|
+
|
|
346
|
+
it('normalizes create responses with weekly resets', () => {
|
|
347
|
+
const normalized = normalizeSdkKeyMetadata(createCreateKeysData());
|
|
348
|
+
|
|
349
|
+
expect(normalized).toMatchObject({
|
|
350
|
+
name: 'Created Key',
|
|
351
|
+
label: 'sk-or-v1-create',
|
|
352
|
+
byok: 'incl',
|
|
353
|
+
resetCadence: 'weekly',
|
|
354
|
+
hash: 'hash-create',
|
|
355
|
+
disabled: false,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('treats null limitReset in update responses as never', () => {
|
|
360
|
+
const normalized = normalizeSdkKeyMetadata(createUpdateKeysData());
|
|
361
|
+
|
|
362
|
+
expect(normalized).toMatchObject({
|
|
363
|
+
name: 'Updated Key',
|
|
364
|
+
label: 'sk-or-v1-update',
|
|
365
|
+
byok: 'excl',
|
|
366
|
+
resetCadence: 'never',
|
|
367
|
+
hash: 'hash-update',
|
|
368
|
+
disabled: false,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
288
371
|
});
|