@robhowley/pi-openrouter 0.9.1 → 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 +26 -5
- 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__/hooks.test.ts +262 -9
- package/extensions/openrouter/__tests__/normalizers.test.ts +86 -3
- package/extensions/openrouter/__tests__/status-bar.test.ts +262 -0
- 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/hooks.ts +70 -11
- package/extensions/openrouter/local-usage.ts +15 -10
- package/extensions/openrouter/normalizers.ts +22 -5
- package/extensions/openrouter/status-bar.ts +101 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-openrouter
|
|
2
2
|
|
|
3
|
-
A [Pi](https://pi.dev/) extension for live OpenRouter visibility and environment sync: usage/account TUI overlays, automatic `session_id` tagging, user-scoped model catalog sync, and local model field overrides.
|
|
3
|
+
A [Pi](https://pi.dev/) extension for live OpenRouter visibility and environment sync: usage/account TUI overlays, automatic `session_id` tagging, user-scoped model catalog sync, api key management, and local model field overrides.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -28,8 +28,9 @@ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
|
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
/openrouter usage # usage/spend overlay
|
|
31
|
-
/openrouter account # credits, key limits, account health
|
|
31
|
+
/openrouter account # credits, key limits, account health, key toggle UI
|
|
32
32
|
/openrouter session # current OpenRouter session_id
|
|
33
|
+
/openrouter api-key-create # create an API key (management key required)
|
|
33
34
|
/openrouter models-sync # sync user-scoped OpenRouter models into Pi
|
|
34
35
|
/openrouter models-status # show model sync/cache status
|
|
35
36
|
/openrouter models-status --skipped # show skipped model reasons
|
|
@@ -48,6 +49,8 @@ The sync uses OpenRouter’s authenticated user model catalog, so Pi can see the
|
|
|
48
49
|
|
|
49
50
|
`/openrouter models-status`
|
|
50
51
|
|
|
52
|
+
Model count and cache health live here. The Pi footer/status bar does not persistently show `OpenRouter {N} models` anymore.
|
|
53
|
+
|
|
51
54
|
Example status output:
|
|
52
55
|
|
|
53
56
|
```text
|
|
@@ -61,7 +64,7 @@ To see why models were skipped:
|
|
|
61
64
|
|
|
62
65
|
Skipped output may include a grouped suggestion when Pi can offer a safe next step, such as adding a local `contextWindow` override for incomplete metadata.
|
|
63
66
|
|
|
64
|
-
Skipped models do not make the sync fail; models are skipped when required metadata cannot be safely mapped into Pi’s provider model config. The last successful catalog is cached so Pi can keep using it if a later refresh fails, and the cache persists across sessions. If a session starts with a cached catalog that has not been registered yet, status will show:
|
|
67
|
+
Skipped models do not make the sync fail; models are skipped when required metadata cannot be safely mapped into Pi’s provider model config. The last successful catalog is cached so Pi can keep using it if a later refresh fails, and the cache persists across sessions. If a session starts with a cached catalog that has not been registered yet, `/openrouter models-status` will show:
|
|
65
68
|
|
|
66
69
|
```text
|
|
67
70
|
OpenRouter models cached
|
|
@@ -101,10 +104,24 @@ The overlay shows:
|
|
|
101
104
|
- **BYOK limit behavior**
|
|
102
105
|
- **All visible keys**, when a management key is configured
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
Use `/openrouter account`, select a key with the arrow keys, press `t` to toggle enabled/disabled, and press Enter to confirm. You can still inspect the selected key's limit, usage, reset cadence, and BYOK behavior from the same overlay.
|
|
105
108
|
|
|
106
109
|
<img src="https://raw.githubusercontent.com/robhowley/pi-userland/main/packages/pi-openrouter/img/openrouter-account-tui.png" alt="OpenRouter Account Overlay" width="600">
|
|
107
110
|
|
|
111
|
+
## API key management
|
|
112
|
+
|
|
113
|
+
API key management requires `OPENROUTER_MANAGEMENT_KEY`. `OPENROUTER_API_KEY` alone is not enough for key creation or account-level toggles.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
/openrouter api-key-create <name> [limit=<usd|none>] [reset=<daily|weekly|monthly|none>] [byok=<incl|excl>] [workspace=<id>] [expires=<UTC ISO>]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Wrap `<name>` in quotes when it contains spaces, for example `/openrouter api-key-create "Team Key" limit=25`.
|
|
120
|
+
|
|
121
|
+
To enable or disable a key, use `/openrouter account`, select the key with the arrow keys, press `t` to toggle it, and press Enter to confirm.
|
|
122
|
+
|
|
123
|
+
When you create a key, Pi shows the returned secret once in an ephemeral overlay. Copy/store it immediately, do not expect Pi or OpenRouter to reveal it again, and avoid logging it or writing it to local files/debug output.
|
|
124
|
+
|
|
108
125
|
## Session tracking
|
|
109
126
|
|
|
110
127
|
`pi-openrouter` automatically tags OpenRouter requests with a `session_id` derived from the Pi session ID.
|
|
@@ -122,7 +139,11 @@ pi:[uuid]
|
|
|
122
139
|
|
|
123
140
|
## Local usage tracking
|
|
124
141
|
|
|
125
|
-
The extension logs completed OpenRouter turns to local JSONL files in `~/.pi/openrouter/usage/` to provide near-real-time usage data for "Today's spend" in the usage overlay. This supplements the OpenRouter Activity API, which typically has a delay.
|
|
142
|
+
The extension logs completed OpenRouter turns to local JSONL files in `~/.pi/openrouter/usage/` to provide near-real-time usage data for the footer/status bar and for "Today's spend" in the usage overlay. This supplements the OpenRouter Activity API, which typically has a delay.
|
|
143
|
+
|
|
144
|
+
When positive local spend exists in the last 30 UTC days, the footer/status bar shows local-only Today spend and a 30-day average multiplier, for example `OR $2.14 today · 1.3x 30d avg`.
|
|
145
|
+
|
|
146
|
+
Model count and cache health remain available through `/openrouter models-status`.
|
|
126
147
|
|
|
127
148
|
**Retention:** Local usage files are automatically cleaned up after 90 days.
|
|
128
149
|
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@openrouter/sdk/sdk/sdk.js', () => ({
|
|
4
|
+
OpenRouter: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('../client.js', () => ({
|
|
8
|
+
AuthError: class AuthError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'AuthError';
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
ApiError: class ApiError extends Error {
|
|
15
|
+
statusCode?: number;
|
|
16
|
+
|
|
17
|
+
constructor(message: string, statusCode?: number) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'ApiError';
|
|
20
|
+
if (statusCode !== undefined) {
|
|
21
|
+
this.statusCode = statusCode;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
function setKeys(apiKey?: string, mgmtKey?: string) {
|
|
28
|
+
if (apiKey === undefined) {
|
|
29
|
+
delete process.env['OPENROUTER_API_KEY'];
|
|
30
|
+
} else {
|
|
31
|
+
process.env['OPENROUTER_API_KEY'] = apiKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (mgmtKey === undefined) {
|
|
35
|
+
delete process.env['OPENROUTER_MANAGEMENT_KEY'];
|
|
36
|
+
} else {
|
|
37
|
+
process.env['OPENROUTER_MANAGEMENT_KEY'] = mgmtKey;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createMockSDKClient(
|
|
42
|
+
overrides: {
|
|
43
|
+
create?: ReturnType<typeof vi.fn>;
|
|
44
|
+
update?: ReturnType<typeof vi.fn>;
|
|
45
|
+
} = {},
|
|
46
|
+
) {
|
|
47
|
+
return {
|
|
48
|
+
credits: { getCredits: vi.fn() },
|
|
49
|
+
workspaces: { list: vi.fn() },
|
|
50
|
+
apiKeys: {
|
|
51
|
+
create: overrides.create ?? vi.fn(),
|
|
52
|
+
update: overrides.update ?? vi.fn(),
|
|
53
|
+
list: vi.fn(),
|
|
54
|
+
getCurrentKeyMetadata: vi.fn(),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createInventoryKey(overrides: Record<string, unknown> = {}) {
|
|
60
|
+
return {
|
|
61
|
+
name: 'Primary',
|
|
62
|
+
label: 'sk-or-v1-123',
|
|
63
|
+
status: 'healthy',
|
|
64
|
+
used: 10,
|
|
65
|
+
remaining: 90,
|
|
66
|
+
limit: 100,
|
|
67
|
+
resetCadence: 'monthly',
|
|
68
|
+
byok: 'incl',
|
|
69
|
+
hash: 'hash-1',
|
|
70
|
+
disabled: false,
|
|
71
|
+
workspaceName: 'Main Workspace',
|
|
72
|
+
spend: 10,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createCurrentKeyMetadata(overrides: Record<string, unknown> = {}) {
|
|
78
|
+
return {
|
|
79
|
+
byokUsage: 0,
|
|
80
|
+
byokUsageDaily: 0,
|
|
81
|
+
byokUsageMonthly: 0,
|
|
82
|
+
byokUsageWeekly: 0,
|
|
83
|
+
creatorUserId: null,
|
|
84
|
+
includeByokInLimit: true,
|
|
85
|
+
isFreeTier: false,
|
|
86
|
+
isManagementKey: true,
|
|
87
|
+
isProvisioningKey: false,
|
|
88
|
+
label: 'sk-or-v1-123',
|
|
89
|
+
limit: 100,
|
|
90
|
+
limitRemaining: 90,
|
|
91
|
+
limitReset: 'monthly',
|
|
92
|
+
usage: 10,
|
|
93
|
+
usageDaily: 0,
|
|
94
|
+
usageMonthly: 0,
|
|
95
|
+
usageWeekly: 0,
|
|
96
|
+
...overrides,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('account-client api key management', () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.resetAllMocks();
|
|
103
|
+
vi.resetModules();
|
|
104
|
+
setKeys(undefined, undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns a missing-api-key inventory result when no OpenRouter key is configured', async () => {
|
|
108
|
+
const { getAllKeys } = await import('../account-client.js');
|
|
109
|
+
|
|
110
|
+
await expect(getAllKeys()).resolves.toEqual({
|
|
111
|
+
keys: [],
|
|
112
|
+
canManageKeys: false,
|
|
113
|
+
degradedReason: 'missing-api-key',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns an empty but manageable inventory when key listing succeeds without keys', async () => {
|
|
118
|
+
setKeys(undefined, 'mgmt-key');
|
|
119
|
+
|
|
120
|
+
const mockClient = createMockSDKClient();
|
|
121
|
+
mockClient.workspaces.list.mockResolvedValue({
|
|
122
|
+
async *[Symbol.asyncIterator]() {
|
|
123
|
+
yield { result: { data: [] } };
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
128
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
129
|
+
|
|
130
|
+
const { getAllKeys } = await import('../account-client.js');
|
|
131
|
+
|
|
132
|
+
await expect(getAllKeys()).resolves.toEqual({
|
|
133
|
+
keys: [],
|
|
134
|
+
canManageKeys: true,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns an explicit degraded inventory when key management is unavailable', async () => {
|
|
139
|
+
setKeys('plain-api-key', undefined);
|
|
140
|
+
|
|
141
|
+
const mockClient = createMockSDKClient();
|
|
142
|
+
mockClient.workspaces.list.mockRejectedValue({ status: 403, message: 'Forbidden' });
|
|
143
|
+
|
|
144
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
145
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
146
|
+
|
|
147
|
+
const { getAllKeys } = await import('../account-client.js');
|
|
148
|
+
|
|
149
|
+
await expect(getAllKeys()).resolves.toEqual({
|
|
150
|
+
keys: [],
|
|
151
|
+
canManageKeys: false,
|
|
152
|
+
degradedReason: 'management-unavailable',
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('resolves an inventory-match relation from a unique inventory label match', async () => {
|
|
157
|
+
setKeys(undefined, 'mgmt-key');
|
|
158
|
+
|
|
159
|
+
const mockClient = createMockSDKClient();
|
|
160
|
+
mockClient.apiKeys.getCurrentKeyMetadata.mockResolvedValue({
|
|
161
|
+
data: createCurrentKeyMetadata({ label: 'sk-or-v1-active' }),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
165
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
166
|
+
|
|
167
|
+
const { resolveCurrentKeyRelation } = await import('../account-client.js');
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
resolveCurrentKeyRelation([
|
|
171
|
+
createInventoryKey({
|
|
172
|
+
name: 'default-space-key',
|
|
173
|
+
label: 'sk-or-v1-active',
|
|
174
|
+
hash: 'hash-active',
|
|
175
|
+
workspaceName: 'Default',
|
|
176
|
+
}),
|
|
177
|
+
createInventoryKey({
|
|
178
|
+
name: 'other-key',
|
|
179
|
+
label: 'sk-or-v1-other',
|
|
180
|
+
hash: 'hash-other',
|
|
181
|
+
workspaceName: 'Dev',
|
|
182
|
+
}),
|
|
183
|
+
] as any),
|
|
184
|
+
).resolves.toEqual({
|
|
185
|
+
kind: 'inventory-match',
|
|
186
|
+
hash: 'hash-active',
|
|
187
|
+
label: 'sk-or-v1-active',
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('resolves an external-provisioning relation when the current provisioning key is outside inventory', async () => {
|
|
192
|
+
setKeys(undefined, 'mgmt-key');
|
|
193
|
+
|
|
194
|
+
const mockClient = createMockSDKClient();
|
|
195
|
+
mockClient.apiKeys.getCurrentKeyMetadata.mockResolvedValue({
|
|
196
|
+
data: createCurrentKeyMetadata({
|
|
197
|
+
label: 'sk-or-v1-provisioning',
|
|
198
|
+
isProvisioningKey: true,
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
203
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
204
|
+
|
|
205
|
+
const { resolveCurrentKeyRelation } = await import('../account-client.js');
|
|
206
|
+
|
|
207
|
+
await expect(
|
|
208
|
+
resolveCurrentKeyRelation([
|
|
209
|
+
createInventoryKey({
|
|
210
|
+
name: 'default-space-key',
|
|
211
|
+
label: 'sk-or-v1-active',
|
|
212
|
+
hash: 'hash-active',
|
|
213
|
+
workspaceName: 'Default',
|
|
214
|
+
}),
|
|
215
|
+
] as any),
|
|
216
|
+
).resolves.toEqual({
|
|
217
|
+
kind: 'external-provisioning',
|
|
218
|
+
label: 'sk-or-v1-provisioning',
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('returns an ambiguous-label relation when multiple inventory rows share the current label', async () => {
|
|
223
|
+
setKeys(undefined, 'mgmt-key');
|
|
224
|
+
|
|
225
|
+
const mockClient = createMockSDKClient();
|
|
226
|
+
mockClient.apiKeys.getCurrentKeyMetadata.mockResolvedValue({
|
|
227
|
+
data: createCurrentKeyMetadata({ label: 'sk-or-v1-active' }),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
231
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
232
|
+
|
|
233
|
+
const { resolveCurrentKeyRelation } = await import('../account-client.js');
|
|
234
|
+
|
|
235
|
+
await expect(
|
|
236
|
+
resolveCurrentKeyRelation([
|
|
237
|
+
createInventoryKey({
|
|
238
|
+
name: 'default-space-key',
|
|
239
|
+
label: 'sk-or-v1-active',
|
|
240
|
+
hash: 'hash-active-1',
|
|
241
|
+
workspaceName: 'Default',
|
|
242
|
+
}),
|
|
243
|
+
createInventoryKey({
|
|
244
|
+
name: 'duplicate-label-key',
|
|
245
|
+
label: 'sk-or-v1-active',
|
|
246
|
+
hash: 'hash-active-2',
|
|
247
|
+
workspaceName: 'Dev',
|
|
248
|
+
}),
|
|
249
|
+
] as any),
|
|
250
|
+
).resolves.toEqual({
|
|
251
|
+
kind: 'ambiguous-label',
|
|
252
|
+
label: 'sk-or-v1-active',
|
|
253
|
+
matchingHashes: ['hash-active-1', 'hash-active-2'],
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('createApiKey sends the expected request body and returns the secret with normalized mutation state', async () => {
|
|
258
|
+
setKeys(undefined, 'mgmt-key');
|
|
259
|
+
|
|
260
|
+
const expiresAt = new Date('2026-06-01T00:00:00Z');
|
|
261
|
+
const mockClient = createMockSDKClient({
|
|
262
|
+
create: vi.fn().mockResolvedValue({
|
|
263
|
+
key: 'sk-or-v1-created-secret',
|
|
264
|
+
data: {
|
|
265
|
+
byokUsage: 0,
|
|
266
|
+
byokUsageDaily: 0,
|
|
267
|
+
byokUsageMonthly: 0,
|
|
268
|
+
byokUsageWeekly: 0,
|
|
269
|
+
createdAt: '2026-05-30T00:00:00.000Z',
|
|
270
|
+
creatorUserId: null,
|
|
271
|
+
disabled: false,
|
|
272
|
+
hash: 'hash-create',
|
|
273
|
+
includeByokInLimit: false,
|
|
274
|
+
label: 'sk-or-v1-created',
|
|
275
|
+
limit: null,
|
|
276
|
+
limitRemaining: null,
|
|
277
|
+
limitReset: null,
|
|
278
|
+
name: 'Team Key',
|
|
279
|
+
updatedAt: null,
|
|
280
|
+
usage: 0,
|
|
281
|
+
usageDaily: 0,
|
|
282
|
+
usageMonthly: 0,
|
|
283
|
+
usageWeekly: 0,
|
|
284
|
+
workspaceId: 'ws-1',
|
|
285
|
+
expiresAt,
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
291
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
292
|
+
|
|
293
|
+
const { createApiKey } = await import('../account-client.js');
|
|
294
|
+
const result = await createApiKey({
|
|
295
|
+
name: 'Team Key',
|
|
296
|
+
limit: null,
|
|
297
|
+
limitReset: null,
|
|
298
|
+
includeByokInLimit: false,
|
|
299
|
+
workspaceId: 'ws-1',
|
|
300
|
+
expiresAt,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(mockClient.apiKeys.create).toHaveBeenCalledWith({
|
|
304
|
+
requestBody: {
|
|
305
|
+
name: 'Team Key',
|
|
306
|
+
limit: null,
|
|
307
|
+
limitReset: null,
|
|
308
|
+
includeByokInLimit: false,
|
|
309
|
+
workspaceId: 'ws-1',
|
|
310
|
+
expiresAt,
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
expect(result).toMatchObject({
|
|
314
|
+
key: 'sk-or-v1-created-secret',
|
|
315
|
+
keyState: {
|
|
316
|
+
name: 'Team Key',
|
|
317
|
+
hash: 'hash-create',
|
|
318
|
+
byok: 'excl',
|
|
319
|
+
resetCadence: 'never',
|
|
320
|
+
status: 'unbounded',
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
expect(result.keyState).not.toHaveProperty('workspaceName');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('setApiKeyDisabled patches disabled status and returns normalized mutation state', async () => {
|
|
327
|
+
setKeys(undefined, 'mgmt-key');
|
|
328
|
+
|
|
329
|
+
const mockClient = createMockSDKClient({
|
|
330
|
+
update: vi.fn().mockResolvedValue({
|
|
331
|
+
data: {
|
|
332
|
+
byokUsage: 0,
|
|
333
|
+
byokUsageDaily: 0,
|
|
334
|
+
byokUsageMonthly: 0,
|
|
335
|
+
byokUsageWeekly: 0,
|
|
336
|
+
createdAt: '2026-05-30T00:00:00.000Z',
|
|
337
|
+
creatorUserId: null,
|
|
338
|
+
disabled: true,
|
|
339
|
+
hash: 'hash-disable',
|
|
340
|
+
includeByokInLimit: true,
|
|
341
|
+
label: 'sk-or-v1-disable',
|
|
342
|
+
limit: 100,
|
|
343
|
+
limitRemaining: 100,
|
|
344
|
+
limitReset: 'weekly',
|
|
345
|
+
name: 'Mutable Key',
|
|
346
|
+
updatedAt: null,
|
|
347
|
+
usage: 0,
|
|
348
|
+
usageDaily: 0,
|
|
349
|
+
usageMonthly: 0,
|
|
350
|
+
usageWeekly: 0,
|
|
351
|
+
workspaceId: 'ws-2',
|
|
352
|
+
},
|
|
353
|
+
}),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
357
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
358
|
+
|
|
359
|
+
const { setApiKeyDisabled } = await import('../account-client.js');
|
|
360
|
+
const result = await setApiKeyDisabled('hash-disable', true);
|
|
361
|
+
|
|
362
|
+
expect(mockClient.apiKeys.update).toHaveBeenCalledWith({
|
|
363
|
+
hash: 'hash-disable',
|
|
364
|
+
requestBody: { disabled: true },
|
|
365
|
+
});
|
|
366
|
+
expect(result).toMatchObject({
|
|
367
|
+
name: 'Mutable Key',
|
|
368
|
+
hash: 'hash-disable',
|
|
369
|
+
disabled: true,
|
|
370
|
+
byok: 'incl',
|
|
371
|
+
resetCadence: 'weekly',
|
|
372
|
+
status: 'disabled',
|
|
373
|
+
});
|
|
374
|
+
expect(result).not.toHaveProperty('workspaceName');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('requires OPENROUTER_MANAGEMENT_KEY for create operations', async () => {
|
|
378
|
+
setKeys('plain-api-key', undefined);
|
|
379
|
+
|
|
380
|
+
const { createApiKey } = await import('../account-client.js');
|
|
381
|
+
|
|
382
|
+
await expect(createApiKey({ name: 'Team Key' })).rejects.toMatchObject({
|
|
383
|
+
message: 'OPENROUTER_MANAGEMENT_KEY is required for API key management.',
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('maps unauthorized create errors to a management-key-required message', async () => {
|
|
388
|
+
setKeys(undefined, 'bad-mgmt-key');
|
|
389
|
+
|
|
390
|
+
const mockClient = createMockSDKClient({
|
|
391
|
+
create: vi.fn().mockRejectedValue({ status: 401, message: 'Unauthorized' }),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
395
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
396
|
+
|
|
397
|
+
const { createApiKey } = await import('../account-client.js');
|
|
398
|
+
|
|
399
|
+
await expect(createApiKey({ name: 'Team Key' })).rejects.toMatchObject({
|
|
400
|
+
message:
|
|
401
|
+
'OPENROUTER_MANAGEMENT_KEY is required to create API keys. Set it to a valid management key.',
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('maps forbidden create errors to a management-key permissions message', async () => {
|
|
406
|
+
setKeys(undefined, 'mgmt-key');
|
|
407
|
+
|
|
408
|
+
const mockClient = createMockSDKClient({
|
|
409
|
+
create: vi.fn().mockRejectedValue({ status: 403, message: 'Forbidden' }),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
413
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
414
|
+
|
|
415
|
+
const { createApiKey } = await import('../account-client.js');
|
|
416
|
+
|
|
417
|
+
await expect(createApiKey({ name: 'Team Key' })).rejects.toMatchObject({
|
|
418
|
+
message:
|
|
419
|
+
'OPENROUTER_MANAGEMENT_KEY does not have permission to create API keys. Set it to a valid management key.',
|
|
420
|
+
statusCode: 403,
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('requires OPENROUTER_MANAGEMENT_KEY for toggle operations', async () => {
|
|
425
|
+
setKeys('plain-api-key', undefined);
|
|
426
|
+
|
|
427
|
+
const { setApiKeyDisabled } = await import('../account-client.js');
|
|
428
|
+
|
|
429
|
+
await expect(setApiKeyDisabled('hash-disable', true)).rejects.toMatchObject({
|
|
430
|
+
message: 'OPENROUTER_MANAGEMENT_KEY is required for API key management.',
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('maps unauthorized disable errors to a management-key-required message', async () => {
|
|
435
|
+
setKeys(undefined, 'bad-mgmt-key');
|
|
436
|
+
|
|
437
|
+
const mockClient = createMockSDKClient({
|
|
438
|
+
update: vi.fn().mockRejectedValue({ status: 401, message: 'Unauthorized' }),
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
442
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
443
|
+
|
|
444
|
+
const { setApiKeyDisabled } = await import('../account-client.js');
|
|
445
|
+
|
|
446
|
+
await expect(setApiKeyDisabled('hash-disable', true)).rejects.toMatchObject({
|
|
447
|
+
message:
|
|
448
|
+
'OPENROUTER_MANAGEMENT_KEY is required to disable API keys. Set it to a valid management key.',
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('maps forbidden enable errors to a management-key permissions message', async () => {
|
|
453
|
+
setKeys(undefined, 'mgmt-key');
|
|
454
|
+
|
|
455
|
+
const mockClient = createMockSDKClient({
|
|
456
|
+
update: vi.fn().mockRejectedValue({ status: 403, message: 'Forbidden' }),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
460
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
461
|
+
|
|
462
|
+
const { setApiKeyDisabled } = await import('../account-client.js');
|
|
463
|
+
|
|
464
|
+
await expect(setApiKeyDisabled('hash-enable', false)).rejects.toMatchObject({
|
|
465
|
+
message:
|
|
466
|
+
'OPENROUTER_MANAGEMENT_KEY does not have permission to enable API keys. Set it to a valid management key.',
|
|
467
|
+
statusCode: 403,
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('preserves not-found update messages and status codes', async () => {
|
|
472
|
+
setKeys(undefined, 'mgmt-key');
|
|
473
|
+
|
|
474
|
+
const mockClient = createMockSDKClient({
|
|
475
|
+
update: vi.fn().mockRejectedValue({ status: 404, message: 'Key not found' }),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const { OpenRouter } = await import('@openrouter/sdk/sdk/sdk.js');
|
|
479
|
+
vi.mocked(OpenRouter).mockImplementation(() => mockClient as any);
|
|
480
|
+
|
|
481
|
+
const { setApiKeyDisabled } = await import('../account-client.js');
|
|
482
|
+
|
|
483
|
+
await expect(setApiKeyDisabled('missing-hash', true)).rejects.toMatchObject({
|
|
484
|
+
message: 'Key not found',
|
|
485
|
+
statusCode: 404,
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
});
|