@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { setLocalUsageDir, dedupeLocalUsageEvents } from '../local-usage.js';
|
|
6
|
+
import {
|
|
7
|
+
calculateOpenRouterStatusStats,
|
|
8
|
+
formatOpenRouterStatusBar,
|
|
9
|
+
loadOpenRouterStatusBar,
|
|
10
|
+
loadOpenRouterStatusStats,
|
|
11
|
+
type OpenRouterStatusStats,
|
|
12
|
+
} from '../status-bar.js';
|
|
13
|
+
import type { LocalUsageEvent } from '../types.js';
|
|
14
|
+
|
|
15
|
+
let testDir: string;
|
|
16
|
+
|
|
17
|
+
function createLocalUsageEvent(
|
|
18
|
+
id: string,
|
|
19
|
+
completedAt: string,
|
|
20
|
+
cost: number,
|
|
21
|
+
overrides: Partial<LocalUsageEvent> = {},
|
|
22
|
+
): LocalUsageEvent {
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
generationId: `${id}-generation`,
|
|
26
|
+
sessionId: 'session-test',
|
|
27
|
+
completedAt,
|
|
28
|
+
requests: 1,
|
|
29
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
30
|
+
provider: 'anthropic',
|
|
31
|
+
promptTokens: 10,
|
|
32
|
+
completionTokens: 5,
|
|
33
|
+
cost,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeDailyFile(
|
|
39
|
+
dateUtc: string,
|
|
40
|
+
rows: Array<LocalUsageEvent | string>,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const content = rows
|
|
43
|
+
.map((row) => (typeof row === 'string' ? row : JSON.stringify(row)))
|
|
44
|
+
.join('\n');
|
|
45
|
+
await fs.writeFile(path.join(testDir, `${dateUtc}.jsonl`), `${content}\n`, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
testDir = path.join(
|
|
50
|
+
os.tmpdir(),
|
|
51
|
+
`pi-openrouter-status-bar-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
52
|
+
);
|
|
53
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
54
|
+
setLocalUsageDir(testDir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
setLocalUsageDir(null);
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
vi.doUnmock('../local-usage.js');
|
|
61
|
+
vi.doUnmock('../status-bar.js');
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore cleanup errors.
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('calculateOpenRouterStatusStats', () => {
|
|
71
|
+
it('returns null for no local events', () => {
|
|
72
|
+
expect(calculateOpenRouterStatusStats([], '2026-05-22')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null when the full 30-day window totals zero local spend', () => {
|
|
76
|
+
expect(
|
|
77
|
+
calculateOpenRouterStatusStats(
|
|
78
|
+
[
|
|
79
|
+
createLocalUsageEvent('today-zero', '2026-05-22T09:15:00.000Z', 0),
|
|
80
|
+
createLocalUsageEvent('older-zero', '2026-05-12T09:15:00.000Z', 0),
|
|
81
|
+
],
|
|
82
|
+
'2026-05-22',
|
|
83
|
+
),
|
|
84
|
+
).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('includes only UTC-today spend and divides by exactly 30 calendar days', () => {
|
|
88
|
+
const stats = calculateOpenRouterStatusStats(
|
|
89
|
+
[
|
|
90
|
+
createLocalUsageEvent('today', '2026-05-22T09:15:00.000Z', 3),
|
|
91
|
+
createLocalUsageEvent('yesterday', '2026-05-21T09:15:00.000Z', 9),
|
|
92
|
+
],
|
|
93
|
+
'2026-05-22',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(stats).toEqual({
|
|
97
|
+
todayLocalSpend: 3,
|
|
98
|
+
averageLocalDailySpendLast30Days: 0.4,
|
|
99
|
+
burnRateMultiplier: 7.5,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses only the today-29 through today window', () => {
|
|
104
|
+
const stats = calculateOpenRouterStatusStats(
|
|
105
|
+
[
|
|
106
|
+
createLocalUsageEvent('old', '2026-04-22T12:00:00.000Z', 100),
|
|
107
|
+
createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 6),
|
|
108
|
+
createLocalUsageEvent('recent', '2026-05-21T12:00:00.000Z', 3),
|
|
109
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 1.5),
|
|
110
|
+
],
|
|
111
|
+
'2026-05-22',
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(stats).toEqual({
|
|
115
|
+
todayLocalSpend: 1.5,
|
|
116
|
+
averageLocalDailySpendLast30Days: 0.35,
|
|
117
|
+
burnRateMultiplier: 1.5 / 0.35,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('deduplicates event ids exactly once across the full 30-day window', () => {
|
|
122
|
+
const events = [
|
|
123
|
+
createLocalUsageEvent('duplicate-id', '2026-05-20T12:00:00.000Z', 1),
|
|
124
|
+
createLocalUsageEvent('duplicate-id', '2026-05-22T12:00:00.000Z', 99),
|
|
125
|
+
createLocalUsageEvent('unique-id', '2026-05-22T13:00:00.000Z', 2),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
expect(dedupeLocalUsageEvents(events)).toHaveLength(2);
|
|
129
|
+
expect(calculateOpenRouterStatusStats(events, '2026-05-22')).toEqual({
|
|
130
|
+
todayLocalSpend: 2,
|
|
131
|
+
averageLocalDailySpendLast30Days: 0.1,
|
|
132
|
+
burnRateMultiplier: 20,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('formatOpenRouterStatusBar', () => {
|
|
138
|
+
it('formats the representative status text exactly', () => {
|
|
139
|
+
const stats: OpenRouterStatusStats = {
|
|
140
|
+
todayLocalSpend: 2.14,
|
|
141
|
+
averageLocalDailySpendLast30Days: 1.64,
|
|
142
|
+
burnRateMultiplier: 1.3,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
expect(formatOpenRouterStatusBar(stats)).toBe('OR $2.14 today · 1.3x 30d avg');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('formats $0.00 today · 0.0x 30d avg when prior 30-day spend exists but today spend is zero', () => {
|
|
149
|
+
const stats: OpenRouterStatusStats = {
|
|
150
|
+
todayLocalSpend: 0,
|
|
151
|
+
averageLocalDailySpendLast30Days: 0.5,
|
|
152
|
+
burnRateMultiplier: 0,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
expect(formatOpenRouterStatusBar(stats)).toBe('OR $0.00 today · 0.0x 30d avg');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('omits the multiplier when given a stats object with no denominator', () => {
|
|
159
|
+
expect(
|
|
160
|
+
formatOpenRouterStatusBar({
|
|
161
|
+
todayLocalSpend: 2.14,
|
|
162
|
+
averageLocalDailySpendLast30Days: 0,
|
|
163
|
+
burnRateMultiplier: null,
|
|
164
|
+
}),
|
|
165
|
+
).toBe('OR $2.14 today');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('loadOpenRouterStatusStats and loadOpenRouterStatusBar', () => {
|
|
170
|
+
it('returns an empty result for empty local usage data', async () => {
|
|
171
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
172
|
+
|
|
173
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
|
|
174
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns an empty result when the 30-day window has only zero-cost local rows', async () => {
|
|
178
|
+
await writeDailyFile('2026-05-12', [
|
|
179
|
+
createLocalUsageEvent('older-zero', '2026-05-12T12:00:00.000Z', 0),
|
|
180
|
+
]);
|
|
181
|
+
await writeDailyFile('2026-05-22', [
|
|
182
|
+
createLocalUsageEvent('today-zero', '2026-05-22T12:00:00.000Z', 0),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
186
|
+
|
|
187
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
|
|
188
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('requests local usage only from today-29 through today', async () => {
|
|
192
|
+
vi.resetModules();
|
|
193
|
+
const actualLocalUsage =
|
|
194
|
+
await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
|
|
195
|
+
const readLocalUsage = vi
|
|
196
|
+
.fn()
|
|
197
|
+
.mockResolvedValue([
|
|
198
|
+
createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 1),
|
|
199
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
vi.doMock('../local-usage.js', () => ({
|
|
203
|
+
...actualLocalUsage,
|
|
204
|
+
readLocalUsage,
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
const { loadOpenRouterStatusStats: loadMockedStats } = await import('../status-bar.js');
|
|
208
|
+
|
|
209
|
+
await expect(loadMockedStats(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
|
|
210
|
+
todayLocalSpend: 2,
|
|
211
|
+
averageLocalDailySpendLast30Days: 0.1,
|
|
212
|
+
burnRateMultiplier: 20,
|
|
213
|
+
});
|
|
214
|
+
expect(readLocalUsage).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(readLocalUsage).toHaveBeenCalledWith({
|
|
216
|
+
fromDateUtc: '2026-04-23',
|
|
217
|
+
toDateUtc: '2026-05-22',
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('tolerates missing files and malformed rows while returning a ready status', async () => {
|
|
222
|
+
await writeDailyFile('2026-05-10', [
|
|
223
|
+
createLocalUsageEvent('older', '2026-05-10T12:00:00.000Z', 4),
|
|
224
|
+
'{not json}',
|
|
225
|
+
]);
|
|
226
|
+
await writeDailyFile('2026-05-22', [
|
|
227
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
|
|
228
|
+
'',
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
232
|
+
|
|
233
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toEqual({
|
|
234
|
+
todayLocalSpend: 2,
|
|
235
|
+
averageLocalDailySpendLast30Days: 0.2,
|
|
236
|
+
burnRateMultiplier: 10,
|
|
237
|
+
});
|
|
238
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({
|
|
239
|
+
kind: 'ready',
|
|
240
|
+
text: 'OR $2.00 today · 10.0x 30d avg',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns a failed result when the local usage read path throws unexpectedly', async () => {
|
|
245
|
+
vi.resetModules();
|
|
246
|
+
|
|
247
|
+
const readLocalUsage = vi.fn().mockRejectedValue(new Error('disk exploded'));
|
|
248
|
+
vi.doMock('../local-usage.js', async () => {
|
|
249
|
+
const actual = await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
|
|
250
|
+
return {
|
|
251
|
+
...actual,
|
|
252
|
+
readLocalUsage,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const { loadOpenRouterStatusBar: loadMockedBar } = await import('../status-bar.js');
|
|
257
|
+
|
|
258
|
+
await expect(loadMockedBar(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
|
|
259
|
+
kind: 'failed',
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -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
|
}
|