@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.
@@ -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([keyInfo]);
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
- 'OpenRouter commands: usage, account, session, models-sync, models-status',
283
+ `OpenRouter commands: ${OPENROUTER_SUBCOMMANDS.join(', ')}`,
242
284
  );
243
285
  });
244
286
 
245
- it('keeps /openrouter subcommand completions unchanged', () => {
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 { GetCurrentKeyData, ListData } from '@openrouter/sdk/models/operations/index.js';
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
  });