@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
|
@@ -1,20 +1,52 @@
|
|
|
1
|
+
import type { CreateKeysRequestBody } from '@openrouter/sdk/models/operations/index.js';
|
|
1
2
|
import { OpenRouter } from '@openrouter/sdk/sdk/sdk.js';
|
|
2
|
-
import type { KeyInfo, KeyStatus } from './account-types.js';
|
|
3
|
+
import type { CurrentKeyRelation, KeyInfo, KeyStatus } from './account-types.js';
|
|
3
4
|
|
|
4
5
|
// Re-export error types from client.ts
|
|
5
6
|
import { AuthError, ApiError } from './client.js';
|
|
6
7
|
import { normalizeSdkKeyMetadata } from './normalizers.js';
|
|
7
8
|
|
|
8
9
|
let client: OpenRouter | null = null;
|
|
10
|
+
let clientApiKey: string | null = null;
|
|
11
|
+
|
|
12
|
+
function getClientForApiKey(apiKey: string): OpenRouter {
|
|
13
|
+
if (client && clientApiKey === apiKey) {
|
|
14
|
+
return client;
|
|
15
|
+
}
|
|
9
16
|
|
|
10
|
-
function getClient(): OpenRouter | null {
|
|
11
|
-
if (client) return client;
|
|
12
|
-
const apiKey = process.env['OPENROUTER_MANAGEMENT_KEY'] || process.env['OPENROUTER_API_KEY'];
|
|
13
|
-
if (!apiKey) return null;
|
|
14
17
|
client = new OpenRouter({ apiKey });
|
|
18
|
+
clientApiKey = apiKey;
|
|
15
19
|
return client;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
function getUsageOrManagementApiKey(): string | undefined {
|
|
23
|
+
const managementKey = process.env['OPENROUTER_MANAGEMENT_KEY'];
|
|
24
|
+
if (managementKey && managementKey.trim() !== '') {
|
|
25
|
+
return managementKey;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const apiKey = process.env['OPENROUTER_API_KEY'];
|
|
29
|
+
if (apiKey && apiKey.trim() !== '') {
|
|
30
|
+
return apiKey;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getClient(): OpenRouter | null {
|
|
37
|
+
const apiKey = getUsageOrManagementApiKey();
|
|
38
|
+
if (!apiKey) return null;
|
|
39
|
+
return getClientForApiKey(apiKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getManagementClient(): OpenRouter {
|
|
43
|
+
const apiKey = process.env['OPENROUTER_MANAGEMENT_KEY'];
|
|
44
|
+
if (!apiKey || apiKey.trim() === '') {
|
|
45
|
+
throw new AuthError('OPENROUTER_MANAGEMENT_KEY is required for API key management.');
|
|
46
|
+
}
|
|
47
|
+
return getClientForApiKey(apiKey);
|
|
48
|
+
}
|
|
49
|
+
|
|
18
50
|
// =============================================================================
|
|
19
51
|
// Account Credits API
|
|
20
52
|
// =============================================================================
|
|
@@ -37,9 +69,23 @@ export async function getAccountCredits(): Promise<number | null> {
|
|
|
37
69
|
// Workspace ID for the default workspace (empty string) - used when workspaceId is not specified
|
|
38
70
|
const DEFAULT_WORKSPACE_ID = '';
|
|
39
71
|
|
|
40
|
-
export
|
|
72
|
+
export type KeyInventoryDegradedReason = 'management-unavailable' | 'missing-api-key';
|
|
73
|
+
|
|
74
|
+
export interface KeyInventoryResult {
|
|
75
|
+
keys: KeyInfo[];
|
|
76
|
+
canManageKeys: boolean;
|
|
77
|
+
degradedReason?: KeyInventoryDegradedReason;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getAllKeys(): Promise<KeyInventoryResult> {
|
|
41
81
|
const client = getClient();
|
|
42
|
-
if (!client)
|
|
82
|
+
if (!client) {
|
|
83
|
+
return {
|
|
84
|
+
keys: [],
|
|
85
|
+
canManageKeys: false,
|
|
86
|
+
degradedReason: 'missing-api-key',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
43
89
|
|
|
44
90
|
try {
|
|
45
91
|
// First, get all workspaces
|
|
@@ -66,12 +112,19 @@ export async function getAllKeys(): Promise<KeyInfo[] | null> {
|
|
|
66
112
|
allKeys.push(...keys);
|
|
67
113
|
}
|
|
68
114
|
|
|
69
|
-
return
|
|
115
|
+
return {
|
|
116
|
+
keys: allKeys,
|
|
117
|
+
canManageKeys: true,
|
|
118
|
+
};
|
|
70
119
|
} catch (err) {
|
|
71
|
-
// If management
|
|
72
|
-
const sdkErr = err as { status?: number };
|
|
73
|
-
if (sdkErr.status === 403) {
|
|
74
|
-
return
|
|
120
|
+
// If management inventory is unavailable (403), fall back to current key only.
|
|
121
|
+
const sdkErr = err as { status?: number; statusCode?: number };
|
|
122
|
+
if ((sdkErr.status ?? sdkErr.statusCode) === 403) {
|
|
123
|
+
return {
|
|
124
|
+
keys: [],
|
|
125
|
+
canManageKeys: false,
|
|
126
|
+
degradedReason: 'management-unavailable',
|
|
127
|
+
};
|
|
75
128
|
}
|
|
76
129
|
throw mapSdkError(err);
|
|
77
130
|
}
|
|
@@ -88,14 +141,162 @@ export async function getCurrentKey(): Promise<KeyInfo | null> {
|
|
|
88
141
|
}
|
|
89
142
|
}
|
|
90
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolve how the current authenticated key relates to the listed inventory rows.
|
|
146
|
+
*
|
|
147
|
+
* OpenRouter current-key metadata does not reliably expose the stable inventory
|
|
148
|
+
* hash, so we fall back to label matching and distinguish external provisioning
|
|
149
|
+
* keys from genuinely unresolved identity.
|
|
150
|
+
*/
|
|
151
|
+
export async function resolveCurrentKeyRelation(keys: KeyInfo[]): Promise<CurrentKeyRelation> {
|
|
152
|
+
if (keys.length === 0) {
|
|
153
|
+
return { kind: 'unresolved', reason: 'no-inventory-keys' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const client = getClient();
|
|
157
|
+
if (!client) {
|
|
158
|
+
return { kind: 'unresolved', reason: 'missing-api-key' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const response = await client.apiKeys.getCurrentKeyMetadata();
|
|
163
|
+
const raw = response.data;
|
|
164
|
+
const currentKey = normalizeSdkKeyMetadata(raw);
|
|
165
|
+
|
|
166
|
+
if (hasTrustedHash(currentKey.hash)) {
|
|
167
|
+
const matchedByHash = keys.find((key) => key.hash === currentKey.hash);
|
|
168
|
+
if (matchedByHash) {
|
|
169
|
+
return {
|
|
170
|
+
kind: 'inventory-match',
|
|
171
|
+
hash: currentKey.hash,
|
|
172
|
+
label: matchedByHash.label,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (raw.isProvisioningKey === true) {
|
|
177
|
+
return { kind: 'external-provisioning', label: currentKey.label };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
kind: 'unresolved',
|
|
182
|
+
reason: 'current-hash-not-in-inventory',
|
|
183
|
+
label: currentKey.label,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const currentLabel = currentKey.label.trim();
|
|
188
|
+
if (currentLabel === '') {
|
|
189
|
+
return { kind: 'unresolved', reason: 'missing-current-label' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const matchingHashes = Array.from(
|
|
193
|
+
new Set(
|
|
194
|
+
keys
|
|
195
|
+
.filter((key) => key.label.trim() === currentLabel)
|
|
196
|
+
.map((key) => (hasTrustedHash(key.hash) ? key.hash : undefined))
|
|
197
|
+
.filter((hash): hash is string => hash !== undefined),
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (matchingHashes.length === 1) {
|
|
202
|
+
return { kind: 'inventory-match', hash: matchingHashes[0]!, label: currentLabel };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (matchingHashes.length > 1) {
|
|
206
|
+
return { kind: 'ambiguous-label', label: currentLabel, matchingHashes };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (raw.isProvisioningKey === true) {
|
|
210
|
+
return { kind: 'external-provisioning', label: currentLabel };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { kind: 'unresolved', reason: 'no-inventory-match', label: currentLabel };
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw mapSdkError(err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface CreateApiKeyInput {
|
|
220
|
+
name: string;
|
|
221
|
+
limit?: number | null;
|
|
222
|
+
limitReset?: CreateKeysRequestBody['limitReset'];
|
|
223
|
+
includeByokInLimit?: boolean;
|
|
224
|
+
workspaceId?: string;
|
|
225
|
+
expiresAt?: Date;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export type ApiKeyMutationInfo = Omit<KeyInfo, 'workspaceName'>;
|
|
229
|
+
|
|
230
|
+
export interface CreatedApiKeyResult {
|
|
231
|
+
key: string;
|
|
232
|
+
keyState: ApiKeyMutationInfo;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKeyResult> {
|
|
236
|
+
const client = getManagementClient();
|
|
237
|
+
|
|
238
|
+
const requestBody: CreateKeysRequestBody = {
|
|
239
|
+
name: input.name,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (input.limit !== undefined) {
|
|
243
|
+
requestBody.limit = input.limit;
|
|
244
|
+
}
|
|
245
|
+
if (input.limitReset !== undefined) {
|
|
246
|
+
requestBody.limitReset = input.limitReset;
|
|
247
|
+
}
|
|
248
|
+
if (input.includeByokInLimit !== undefined) {
|
|
249
|
+
requestBody.includeByokInLimit = input.includeByokInLimit;
|
|
250
|
+
}
|
|
251
|
+
if (input.workspaceId !== undefined) {
|
|
252
|
+
requestBody.workspaceId = input.workspaceId;
|
|
253
|
+
}
|
|
254
|
+
if (input.expiresAt !== undefined) {
|
|
255
|
+
requestBody.expiresAt = input.expiresAt;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const response = await client.apiKeys.create({ requestBody });
|
|
260
|
+
return {
|
|
261
|
+
key: response.key,
|
|
262
|
+
keyState: keyMetadataToMutationInfo(normalizeSdkKeyMetadata(response.data)),
|
|
263
|
+
};
|
|
264
|
+
} catch (err) {
|
|
265
|
+
throw mapManagementSdkError(err, 'create API keys');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function setApiKeyDisabled(
|
|
270
|
+
hash: string,
|
|
271
|
+
disabled: boolean,
|
|
272
|
+
): Promise<ApiKeyMutationInfo> {
|
|
273
|
+
const client = getManagementClient();
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const response = await client.apiKeys.update({
|
|
277
|
+
hash,
|
|
278
|
+
requestBody: {
|
|
279
|
+
disabled,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return keyMetadataToMutationInfo(normalizeSdkKeyMetadata(response.data));
|
|
284
|
+
} catch (err) {
|
|
285
|
+
throw mapManagementSdkError(err, `${disabled ? 'disable' : 'enable'} API keys`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
91
289
|
// =============================================================================
|
|
92
290
|
// Helper Functions
|
|
93
291
|
// =============================================================================
|
|
94
292
|
|
|
95
|
-
function
|
|
293
|
+
function hasTrustedHash(hash?: string): hash is string {
|
|
294
|
+
return typeof hash === 'string' && hash.trim() !== '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function keyMetadataToMutationInfo(
|
|
96
298
|
metadata: ReturnType<typeof normalizeSdkKeyMetadata>,
|
|
97
|
-
|
|
98
|
-
): KeyInfo {
|
|
299
|
+
): ApiKeyMutationInfo {
|
|
99
300
|
const { name, label, used, limit, remaining, resetCadence, byok, hash, disabled } = metadata;
|
|
100
301
|
|
|
101
302
|
// Calculate status based on usage percentage
|
|
@@ -119,7 +320,7 @@ function keyMetadataToKeyInfo(
|
|
|
119
320
|
}
|
|
120
321
|
}
|
|
121
322
|
|
|
122
|
-
const
|
|
323
|
+
const keyState: ApiKeyMutationInfo = {
|
|
123
324
|
name,
|
|
124
325
|
label,
|
|
125
326
|
status,
|
|
@@ -127,39 +328,85 @@ function keyMetadataToKeyInfo(
|
|
|
127
328
|
spend: used, // spend is the same as usage (in USD)
|
|
128
329
|
resetCadence,
|
|
129
330
|
byok,
|
|
130
|
-
hash,
|
|
131
331
|
disabled,
|
|
132
|
-
workspaceName,
|
|
133
332
|
};
|
|
134
333
|
|
|
334
|
+
if (hash !== undefined) {
|
|
335
|
+
keyState.hash = hash;
|
|
336
|
+
}
|
|
135
337
|
if (limit !== undefined) {
|
|
136
|
-
|
|
338
|
+
keyState.limit = limit;
|
|
137
339
|
}
|
|
138
340
|
if (remaining !== undefined) {
|
|
139
|
-
|
|
341
|
+
keyState.remaining = remaining;
|
|
140
342
|
}
|
|
141
343
|
|
|
142
|
-
return
|
|
344
|
+
return keyState;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function keyMetadataToKeyInfo(
|
|
348
|
+
metadata: ReturnType<typeof normalizeSdkKeyMetadata>,
|
|
349
|
+
workspaceName: string,
|
|
350
|
+
): KeyInfo {
|
|
351
|
+
return {
|
|
352
|
+
...keyMetadataToMutationInfo(metadata),
|
|
353
|
+
workspaceName,
|
|
354
|
+
};
|
|
143
355
|
}
|
|
144
356
|
|
|
145
357
|
function mapSdkError(err: unknown): Error {
|
|
146
|
-
const rawErr = err as { status?: number; message?: string };
|
|
147
|
-
const status = rawErr.status;
|
|
358
|
+
const rawErr = err as { status?: number; statusCode?: number; message?: string };
|
|
359
|
+
const status = rawErr.status ?? rawErr.statusCode;
|
|
148
360
|
const message = rawErr.message ?? 'Unknown error';
|
|
149
361
|
|
|
150
362
|
if (status === 401) {
|
|
151
363
|
return new AuthError(message);
|
|
152
364
|
}
|
|
153
365
|
if (status === 403) {
|
|
154
|
-
return new ApiError(`Forbidden: ${message}
|
|
366
|
+
return new ApiError(`Forbidden: ${message}`, 403);
|
|
367
|
+
}
|
|
368
|
+
if (status !== undefined) {
|
|
369
|
+
return new ApiError(message, status);
|
|
155
370
|
}
|
|
156
371
|
|
|
157
372
|
if (err instanceof Error) return err;
|
|
158
373
|
return new Error(String(err));
|
|
159
374
|
}
|
|
160
375
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
376
|
+
function mapManagementSdkError(err: unknown, action: string): Error {
|
|
377
|
+
const rawErr = err as { status?: number; statusCode?: number; message?: string };
|
|
378
|
+
const status = rawErr.status ?? rawErr.statusCode;
|
|
379
|
+
const message = rawErr.message ?? 'Unknown error';
|
|
380
|
+
|
|
381
|
+
if (status === 401) {
|
|
382
|
+
return new AuthError(
|
|
383
|
+
`OPENROUTER_MANAGEMENT_KEY is required to ${action}. Set it to a valid management key.`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (status === 403) {
|
|
388
|
+
return new ApiError(
|
|
389
|
+
`OPENROUTER_MANAGEMENT_KEY does not have permission to ${action}. Set it to a valid management key.`,
|
|
390
|
+
403,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (err instanceof Error && /unauthorized/i.test(err.message)) {
|
|
395
|
+
return new AuthError(
|
|
396
|
+
`OPENROUTER_MANAGEMENT_KEY is required to ${action}. Set it to a valid management key.`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (err instanceof Error && /forbidden/i.test(err.message)) {
|
|
401
|
+
return new ApiError(
|
|
402
|
+
`OPENROUTER_MANAGEMENT_KEY does not have permission to ${action}. Set it to a valid management key.`,
|
|
403
|
+
403,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (status === 400) {
|
|
408
|
+
return new ApiError(message, 400);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return mapSdkError(err);
|
|
165
412
|
}
|