@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
|
@@ -13,7 +13,7 @@ export type KeyStatus =
|
|
|
13
13
|
export type BYOKStatus = 'incl' | 'excl' | '?';
|
|
14
14
|
|
|
15
15
|
/** Reset cadence for key limits */
|
|
16
|
-
export type ResetCadence = 'monthly' | 'daily' | 'never' | 'partial';
|
|
16
|
+
export type ResetCadence = 'monthly' | 'weekly' | 'daily' | 'never' | 'partial';
|
|
17
17
|
|
|
18
18
|
/** Information about a single OpenRouter key */
|
|
19
19
|
export interface KeyInfo {
|
|
@@ -23,14 +23,21 @@ export interface KeyInfo {
|
|
|
23
23
|
used: number; // Current usage (currency)
|
|
24
24
|
limit?: number; // Key cap (optional)
|
|
25
25
|
remaining?: number; // limit - used
|
|
26
|
-
resetCadence: ResetCadence; // monthly, daily, never, or partial
|
|
26
|
+
resetCadence: ResetCadence; // monthly, weekly, daily, never, or partial
|
|
27
27
|
byok: BYOKStatus; // incl (true), excl (false), ? (unavailable)
|
|
28
|
-
hash
|
|
28
|
+
hash?: string; // Trusted key hash for identification/mutation when available
|
|
29
29
|
disabled: boolean; // Whether key is disabled
|
|
30
30
|
workspaceName: string; // Name of the workspace this key belongs to
|
|
31
31
|
spend: number; // Spend associated with this key (in USD)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** How the currently authenticated key relates to listed inventory rows. */
|
|
35
|
+
export type CurrentKeyRelation =
|
|
36
|
+
| { kind: 'inventory-match'; hash: string; label: string }
|
|
37
|
+
| { kind: 'external-provisioning'; label: string }
|
|
38
|
+
| { kind: 'ambiguous-label'; label: string; matchingHashes: string[] }
|
|
39
|
+
| { kind: 'unresolved'; reason: string; label?: string };
|
|
40
|
+
|
|
34
41
|
/** Rollup status for the entire account */
|
|
35
42
|
export type RollupStatus =
|
|
36
43
|
| { status: 'unavailable'; message?: never }
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createApiKey, setApiKeyDisabled } from './account-client.js';
|
|
2
|
+
|
|
3
|
+
export interface HandlerResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
message: string;
|
|
6
|
+
/** Create-only one-time secret. The command router must render this outside notify/log paths. */
|
|
7
|
+
secret?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParsedApiKeyCreateArgs {
|
|
11
|
+
name: string;
|
|
12
|
+
limit?: number | null;
|
|
13
|
+
limitReset?: 'daily' | 'weekly' | 'monthly' | null;
|
|
14
|
+
includeByokInLimit?: boolean;
|
|
15
|
+
workspaceId?: string;
|
|
16
|
+
expiresAt?: Date;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const API_KEY_CREATE_USAGE =
|
|
20
|
+
'Usage: /openrouter api-key-create <name> [limit=<usd|none>] [reset=<daily|weekly|monthly|none>] [byok=<incl|excl>] [workspace=<id>] [expires=<UTC ISO>]';
|
|
21
|
+
const API_KEY_DISABLE_USAGE = 'Usage: /openrouter api-key-disable <hash>';
|
|
22
|
+
const API_KEY_ENABLE_USAGE = 'Usage: /openrouter api-key-enable <hash>';
|
|
23
|
+
|
|
24
|
+
const ALLOWED_RESET_VALUES = new Set(['daily', 'weekly', 'monthly', 'none']);
|
|
25
|
+
const ALLOWED_BYOK_VALUES = new Set(['incl', 'excl']);
|
|
26
|
+
|
|
27
|
+
export function isUtcIsoTimestamp(value: string): boolean {
|
|
28
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parsed = new Date(value);
|
|
33
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const canonical = parsed.toISOString();
|
|
38
|
+
return value === canonical || value === canonical.replace('.000Z', 'Z');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tokenizeArgs(
|
|
42
|
+
args: string,
|
|
43
|
+
): { ok: true; value: string[] } | { ok: false; message: string } {
|
|
44
|
+
const tokens: string[] = [];
|
|
45
|
+
let current = '';
|
|
46
|
+
let quote: '"' | "'" | null = null;
|
|
47
|
+
|
|
48
|
+
for (const char of args.trim()) {
|
|
49
|
+
if (quote) {
|
|
50
|
+
if (char === quote) {
|
|
51
|
+
quote = null;
|
|
52
|
+
} else {
|
|
53
|
+
current += char;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (char === '"' || char === "'") {
|
|
59
|
+
quote = char;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (/\s/.test(char)) {
|
|
64
|
+
if (current) {
|
|
65
|
+
tokens.push(current);
|
|
66
|
+
current = '';
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
current += char;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (quote) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
message: `Unterminated quoted string in arguments.\n${API_KEY_CREATE_USAGE}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (current) {
|
|
82
|
+
tokens.push(current);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { ok: true, value: tokens };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseApiKeyCreateArgs(
|
|
89
|
+
args: string,
|
|
90
|
+
): { ok: true; value: ParsedApiKeyCreateArgs } | { ok: false; message: string } {
|
|
91
|
+
const tokenized = tokenizeArgs(args);
|
|
92
|
+
if (!tokenized.ok) {
|
|
93
|
+
return { ok: false, message: tokenized.message };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parts = tokenized.value;
|
|
97
|
+
const name = parts[0];
|
|
98
|
+
|
|
99
|
+
if (!name || name.includes('=')) {
|
|
100
|
+
return { ok: false, message: API_KEY_CREATE_USAGE };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parsed: ParsedApiKeyCreateArgs = { name };
|
|
104
|
+
|
|
105
|
+
for (const token of parts.slice(1)) {
|
|
106
|
+
const separatorIndex = token.indexOf('=');
|
|
107
|
+
if (separatorIndex === -1) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
message: `Invalid option: "${token}"\nExpected key=value options after the key name.\n${API_KEY_CREATE_USAGE}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const key = token.slice(0, separatorIndex).trim();
|
|
115
|
+
const rawValue = token.slice(separatorIndex + 1).trim();
|
|
116
|
+
|
|
117
|
+
switch (key) {
|
|
118
|
+
case 'limit': {
|
|
119
|
+
if (rawValue === 'none') {
|
|
120
|
+
parsed.limit = null;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (rawValue === '') {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
message: `Invalid limit value: "${rawValue}"\nExpected a non-negative USD amount or 'none'.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const limit = Number(rawValue);
|
|
132
|
+
if (!Number.isFinite(limit) || limit < 0) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
message: `Invalid limit value: "${rawValue}"\nExpected a non-negative USD amount or 'none'.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
parsed.limit = limit;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case 'reset': {
|
|
143
|
+
if (!ALLOWED_RESET_VALUES.has(rawValue)) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
message: `Invalid reset value: "${rawValue}"\nAllowed values: daily, weekly, monthly, none.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
parsed.limitReset =
|
|
151
|
+
rawValue === 'none' ? null : (rawValue as 'daily' | 'weekly' | 'monthly');
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'byok': {
|
|
155
|
+
if (!ALLOWED_BYOK_VALUES.has(rawValue)) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
message: `Invalid byok value: "${rawValue}"\nAllowed values: incl, excl.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
parsed.includeByokInLimit = rawValue === 'incl';
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'workspace': {
|
|
166
|
+
if (!rawValue) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
message: 'Invalid workspace value: workspace id cannot be empty.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
parsed.workspaceId = rawValue;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'expires': {
|
|
177
|
+
if (!isUtcIsoTimestamp(rawValue)) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
message: `Invalid expires value: "${rawValue}"\nExpected an ISO 8601 UTC timestamp ending in Z, for example 2026-06-01T00:00:00Z.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
parsed.expiresAt = new Date(rawValue);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
message: `Unknown option: "${key}"\nAllowed options: limit, reset, byok, workspace, expires.\n${API_KEY_CREATE_USAGE}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { ok: true, value: parsed };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function handleApiKeyCreate(args: string): Promise<HandlerResult> {
|
|
199
|
+
const parsed = parseApiKeyCreateArgs(args);
|
|
200
|
+
if (!parsed.ok) {
|
|
201
|
+
return {
|
|
202
|
+
success: false,
|
|
203
|
+
message: parsed.message,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const created = await createApiKey(parsed.value);
|
|
209
|
+
const status = created.keyState.disabled ? 'disabled' : 'enabled';
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: true,
|
|
213
|
+
message: [
|
|
214
|
+
'OpenRouter API key created',
|
|
215
|
+
`Name: ${created.keyState.name}`,
|
|
216
|
+
`Status: ${status}`,
|
|
217
|
+
'Use /openrouter account to inspect or toggle the key.',
|
|
218
|
+
'Secret shown in secure overlay; store it now.',
|
|
219
|
+
'Warning: This secret cannot be recovered and was not written or cached locally.',
|
|
220
|
+
].join('\n'),
|
|
221
|
+
secret: created.key,
|
|
222
|
+
};
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
message: getErrorMessage(error),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function handleApiKeyDisable(args: string): Promise<HandlerResult> {
|
|
232
|
+
return handleApiKeyToggle(args, true);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function handleApiKeyEnable(args: string): Promise<HandlerResult> {
|
|
236
|
+
return handleApiKeyToggle(args, false);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function handleApiKeyToggle(args: string, disabled: boolean): Promise<HandlerResult> {
|
|
240
|
+
const tokenized = tokenizeArgs(args);
|
|
241
|
+
if (!tokenized.ok) {
|
|
242
|
+
return {
|
|
243
|
+
success: false,
|
|
244
|
+
message: disabled ? API_KEY_DISABLE_USAGE : API_KEY_ENABLE_USAGE,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const parts = tokenized.value;
|
|
249
|
+
const hash = parts[0];
|
|
250
|
+
|
|
251
|
+
if (!hash || parts.length !== 1) {
|
|
252
|
+
return {
|
|
253
|
+
success: false,
|
|
254
|
+
message: disabled ? API_KEY_DISABLE_USAGE : API_KEY_ENABLE_USAGE,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const keyInfo = await setApiKeyDisabled(hash, disabled);
|
|
260
|
+
return {
|
|
261
|
+
success: true,
|
|
262
|
+
message: [
|
|
263
|
+
`OpenRouter API key ${disabled ? 'disabled' : 'enabled'}`,
|
|
264
|
+
`Name: ${keyInfo.name}`,
|
|
265
|
+
`Status: ${keyInfo.disabled ? 'disabled' : 'enabled'}`,
|
|
266
|
+
'Run /openrouter account to verify.',
|
|
267
|
+
].join('\n'),
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
message: getToggleErrorMessage(error, disabled),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getToggleErrorMessage(error: unknown, disabled: boolean): string {
|
|
278
|
+
const message = getErrorMessage(error);
|
|
279
|
+
if (/OPENROUTER_MANAGEMENT_KEY/i.test(message)) {
|
|
280
|
+
return message;
|
|
281
|
+
}
|
|
282
|
+
return `Failed to ${disabled ? 'disable' : 'enable'} OpenRouter API key. Check the key identifier and management-key permissions.`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getErrorMessage(error: unknown): string {
|
|
286
|
+
return error instanceof Error ? error.message : String(error);
|
|
287
|
+
}
|
|
@@ -12,8 +12,13 @@ import { UsageOverlayComponent } from './overlay.js';
|
|
|
12
12
|
import { getCurrentSessionId } from './hooks.js';
|
|
13
13
|
import { AccountOverlayComponent } from './account-overlay.js';
|
|
14
14
|
import { computeRollupStatus, sortKeys } from './account-format.js';
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
getAllKeys,
|
|
17
|
+
getCurrentKey,
|
|
18
|
+
resolveCurrentKeyRelation,
|
|
19
|
+
getAccountCredits,
|
|
20
|
+
} from './account-client.js';
|
|
21
|
+
import type { CurrentKeyRelation, KeyInfo, RollupStatus } from './account-types.js';
|
|
17
22
|
import {
|
|
18
23
|
syncModels,
|
|
19
24
|
getSyncState,
|
|
@@ -30,6 +35,8 @@ import {
|
|
|
30
35
|
handleModelOverrideClear,
|
|
31
36
|
handleModelOverrideList,
|
|
32
37
|
} from './models/override-commands.js';
|
|
38
|
+
import type { HandlerResult } from './api-key-commands.js';
|
|
39
|
+
import { handleApiKeyCreate, handleApiKeyDisable, handleApiKeyEnable } from './api-key-commands.js';
|
|
33
40
|
|
|
34
41
|
export const OPENROUTER_SUBCOMMANDS = [
|
|
35
42
|
'usage',
|
|
@@ -40,6 +47,7 @@ export const OPENROUTER_SUBCOMMANDS = [
|
|
|
40
47
|
'model-override-set',
|
|
41
48
|
'model-override-clear',
|
|
42
49
|
'model-override-list',
|
|
50
|
+
'api-key-create',
|
|
43
51
|
] as const;
|
|
44
52
|
|
|
45
53
|
export function registerOpenRouterCommands(pi: Pick<ExtensionAPI, 'registerCommand'>): void {
|
|
@@ -70,7 +78,7 @@ export function registerOpenRouterCommands(pi: Pick<ExtensionAPI, 'registerComma
|
|
|
70
78
|
});
|
|
71
79
|
|
|
72
80
|
pi.registerCommand('openrouter', {
|
|
73
|
-
description:
|
|
81
|
+
description: `OpenRouter commands: ${OPENROUTER_SUBCOMMANDS.join(', ')}`,
|
|
74
82
|
getArgumentCompletions: getOpenRouterSubcommandCompletions,
|
|
75
83
|
handler: async (args, ctx) => {
|
|
76
84
|
await handleOpenRouterCommand(args, ctx);
|
|
@@ -122,6 +130,19 @@ async function handleOpenRouterCommand(args: string, ctx: ExtensionContext): Pro
|
|
|
122
130
|
await handleModelOverrideListCommand(subcommandArgs, ctx);
|
|
123
131
|
break;
|
|
124
132
|
}
|
|
133
|
+
case 'api-key-create': {
|
|
134
|
+
await handleApiKeyCreateCommand(subcommandArgs, ctx);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
// Back-compat only: hidden from public help/completions in favor of /openrouter account toggle UX.
|
|
138
|
+
case 'api-key-disable': {
|
|
139
|
+
await handleApiKeyDisableCommand(subcommandArgs, ctx);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case 'api-key-enable': {
|
|
143
|
+
await handleApiKeyEnableCommand(subcommandArgs, ctx);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
125
146
|
default: {
|
|
126
147
|
notifyUnknownSubcommand(ctx);
|
|
127
148
|
break;
|
|
@@ -294,6 +315,77 @@ async function handleModelOverrideListCommand(
|
|
|
294
315
|
}
|
|
295
316
|
}
|
|
296
317
|
|
|
318
|
+
async function handleApiKeyCreateCommand(
|
|
319
|
+
subcommandArgs: string,
|
|
320
|
+
ctx: ExtensionContext,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
const result = await handleApiKeyCreate(subcommandArgs);
|
|
323
|
+
if (!result.success) {
|
|
324
|
+
ctx.ui.notify(result.message, 'error');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (result.secret) {
|
|
329
|
+
await showApiKeySecretOverlay(ctx, result);
|
|
330
|
+
}
|
|
331
|
+
ctx.ui.notify(result.message, 'info');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function handleApiKeyDisableCommand(
|
|
335
|
+
subcommandArgs: string,
|
|
336
|
+
ctx: ExtensionContext,
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
const result = await handleApiKeyDisable(subcommandArgs);
|
|
339
|
+
ctx.ui.notify(result.message, result.success ? 'info' : 'error');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function handleApiKeyEnableCommand(
|
|
343
|
+
subcommandArgs: string,
|
|
344
|
+
ctx: ExtensionContext,
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
const result = await handleApiKeyEnable(subcommandArgs);
|
|
347
|
+
ctx.ui.notify(result.message, result.success ? 'info' : 'error');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function showApiKeySecretOverlay(
|
|
351
|
+
ctx: ExtensionContext,
|
|
352
|
+
result: HandlerResult,
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
if (!result.secret) return;
|
|
355
|
+
|
|
356
|
+
const lines = [
|
|
357
|
+
'OpenRouter API key created',
|
|
358
|
+
'',
|
|
359
|
+
...result.message
|
|
360
|
+
.split('\n')
|
|
361
|
+
.filter((line) => line !== 'OpenRouter API key created' && !line.startsWith('Secret shown')),
|
|
362
|
+
'',
|
|
363
|
+
'Secret (store now; shown once):',
|
|
364
|
+
result.secret,
|
|
365
|
+
'',
|
|
366
|
+
'Press any key to close.',
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
await ctx.ui.custom<void>(
|
|
370
|
+
(_tui, theme, _keybindings, done) => ({
|
|
371
|
+
handleInput: () => {
|
|
372
|
+
done();
|
|
373
|
+
},
|
|
374
|
+
render: (_width: number) =>
|
|
375
|
+
lines.map((line, index) => (index === 0 ? theme.bold(line) : line)),
|
|
376
|
+
invalidate: () => {},
|
|
377
|
+
dispose: () => {},
|
|
378
|
+
wantsKeyRelease: false,
|
|
379
|
+
}),
|
|
380
|
+
{
|
|
381
|
+
overlay: true,
|
|
382
|
+
overlayOptions: {
|
|
383
|
+
width: 120,
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
297
389
|
function notifyUnknownSubcommand(ctx: ExtensionContext): void {
|
|
298
390
|
const message = `Available subcommands: ${OPENROUTER_SUBCOMMANDS.join(', ')}`;
|
|
299
391
|
ctx.ui.notify(`OpenRouter subcommands\n${message}`, 'error');
|
|
@@ -336,13 +428,21 @@ async function showAccountOverlay(ctx: ExtensionContext) {
|
|
|
336
428
|
let error: string | null = null;
|
|
337
429
|
let keyInfo: KeyInfo[] | null = null;
|
|
338
430
|
let credits: number | null = null;
|
|
431
|
+
let canManageKeys = false;
|
|
432
|
+
let currentKeyRelation: CurrentKeyRelation | undefined;
|
|
339
433
|
|
|
340
434
|
try {
|
|
341
|
-
const
|
|
435
|
+
const keyInventory = await getAllKeys();
|
|
436
|
+
canManageKeys = keyInventory.canManageKeys;
|
|
342
437
|
|
|
343
|
-
if (
|
|
344
|
-
keyInfo =
|
|
345
|
-
|
|
438
|
+
if (keyInventory.keys.length > 0) {
|
|
439
|
+
keyInfo = keyInventory.keys;
|
|
440
|
+
try {
|
|
441
|
+
currentKeyRelation = await resolveCurrentKeyRelation(keyInfo);
|
|
442
|
+
} catch {
|
|
443
|
+
// Safe gating: disabling stays blocked until current-key identity is available.
|
|
444
|
+
}
|
|
445
|
+
} else if (keyInventory.degradedReason === 'management-unavailable') {
|
|
346
446
|
error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
|
|
347
447
|
|
|
348
448
|
try {
|
|
@@ -360,12 +460,12 @@ async function showAccountOverlay(ctx: ExtensionContext) {
|
|
|
360
460
|
|
|
361
461
|
credits = await getAccountCredits();
|
|
362
462
|
|
|
363
|
-
if (!keyInfo && !credits) {
|
|
463
|
+
if (!keyInfo && !credits && keyInventory.degradedReason === 'missing-api-key') {
|
|
364
464
|
error =
|
|
365
465
|
'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.';
|
|
366
466
|
}
|
|
367
467
|
|
|
368
|
-
if (!keyInfo && credits !== null) {
|
|
468
|
+
if (!keyInfo && credits !== null && keyInventory.degradedReason === 'management-unavailable') {
|
|
369
469
|
error =
|
|
370
470
|
error ||
|
|
371
471
|
'Key information unavailable. Set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
|
|
@@ -379,7 +479,15 @@ async function showAccountOverlay(ctx: ExtensionContext) {
|
|
|
379
479
|
keyInfo = sortKeys(keyInfo);
|
|
380
480
|
}
|
|
381
481
|
|
|
382
|
-
await showAccountOverlayComponent(
|
|
482
|
+
await showAccountOverlayComponent(
|
|
483
|
+
ctx,
|
|
484
|
+
keyInfo,
|
|
485
|
+
credits,
|
|
486
|
+
rollupStatus,
|
|
487
|
+
error,
|
|
488
|
+
canManageKeys,
|
|
489
|
+
currentKeyRelation,
|
|
490
|
+
);
|
|
383
491
|
} catch (error_) {
|
|
384
492
|
const err = error_ as Error;
|
|
385
493
|
error =
|
|
@@ -400,7 +508,7 @@ async function showAccountOverlay(ctx: ExtensionContext) {
|
|
|
400
508
|
? computeRollupStatus(keyInfo)
|
|
401
509
|
: { status: 'unavailable' as const };
|
|
402
510
|
|
|
403
|
-
await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
|
|
511
|
+
await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error, false);
|
|
404
512
|
}
|
|
405
513
|
}
|
|
406
514
|
|
|
@@ -410,6 +518,8 @@ async function showAccountOverlayComponent(
|
|
|
410
518
|
credits: number | null,
|
|
411
519
|
rollupStatus: RollupStatus,
|
|
412
520
|
error: string | null,
|
|
521
|
+
canManageKeys: boolean,
|
|
522
|
+
currentKeyRelation?: CurrentKeyRelation,
|
|
413
523
|
) {
|
|
414
524
|
await ctx.ui.custom<void>(
|
|
415
525
|
(_tui, theme, _keybindings, done) => {
|
|
@@ -422,6 +532,8 @@ async function showAccountOverlayComponent(
|
|
|
422
532
|
done,
|
|
423
533
|
() => _tui.requestRender(),
|
|
424
534
|
ctx,
|
|
535
|
+
canManageKeys,
|
|
536
|
+
currentKeyRelation,
|
|
425
537
|
);
|
|
426
538
|
|
|
427
539
|
return {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
CreateKeysData,
|
|
4
|
+
GetCurrentKeyData,
|
|
5
|
+
GetKeyData,
|
|
6
|
+
ListData,
|
|
7
|
+
UpdateKeysData,
|
|
8
|
+
} from '@openrouter/sdk/models/operations/index.js';
|
|
3
9
|
import type { BYOKStatus, ResetCadence } from './account-types.js';
|
|
4
10
|
import type { OpenRouterModel } from './models/types.js';
|
|
5
11
|
|
|
@@ -9,7 +15,7 @@ export interface NormalizedKeyMetadata {
|
|
|
9
15
|
used: number;
|
|
10
16
|
resetCadence: ResetCadence;
|
|
11
17
|
byok: BYOKStatus;
|
|
12
|
-
hash
|
|
18
|
+
hash?: string;
|
|
13
19
|
disabled: boolean;
|
|
14
20
|
limit?: number;
|
|
15
21
|
remaining?: number;
|
|
@@ -83,7 +89,9 @@ export function normalizeOpenRouterModel(model: OpenRouterModel | SDKModel): Ope
|
|
|
83
89
|
* Normalize SDK key metadata into the package's canonical internal shape.
|
|
84
90
|
* Converts SDK null/variant fields once so account code can stay domain-focused.
|
|
85
91
|
*/
|
|
86
|
-
export function normalizeSdkKeyMetadata(
|
|
92
|
+
export function normalizeSdkKeyMetadata(
|
|
93
|
+
raw: GetCurrentKeyData | ListData | GetKeyData | CreateKeysData | UpdateKeysData,
|
|
94
|
+
): NormalizedKeyMetadata {
|
|
87
95
|
const used = raw.usage ?? raw.usageMonthly ?? 0;
|
|
88
96
|
const limit = raw.limit ?? undefined;
|
|
89
97
|
const remaining = raw.limitRemaining ?? undefined;
|
|
@@ -96,10 +104,14 @@ export function normalizeSdkKeyMetadata(raw: GetCurrentKeyData | ListData): Norm
|
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
let resetCadence: ResetCadence = 'partial';
|
|
99
|
-
if (raw.limitReset) {
|
|
107
|
+
if (raw.limitReset === null) {
|
|
108
|
+
resetCadence = 'never';
|
|
109
|
+
} else if (raw.limitReset) {
|
|
100
110
|
const reset = raw.limitReset.toLowerCase();
|
|
101
111
|
if (reset === 'monthly') {
|
|
102
112
|
resetCadence = 'monthly';
|
|
113
|
+
} else if (reset === 'weekly') {
|
|
114
|
+
resetCadence = 'weekly';
|
|
103
115
|
} else if (reset === 'daily') {
|
|
104
116
|
resetCadence = 'daily';
|
|
105
117
|
} else if (reset === 'never') {
|
|
@@ -107,16 +119,21 @@ export function normalizeSdkKeyMetadata(raw: GetCurrentKeyData | ListData): Norm
|
|
|
107
119
|
}
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
const hash =
|
|
123
|
+
'hash' in raw && typeof raw.hash === 'string' && raw.hash.trim() !== '' ? raw.hash : undefined;
|
|
124
|
+
|
|
110
125
|
const normalized: NormalizedKeyMetadata = {
|
|
111
126
|
name: 'name' in raw ? (raw as ListData).name : raw.label,
|
|
112
127
|
label: raw.label,
|
|
113
128
|
used,
|
|
114
129
|
resetCadence,
|
|
115
130
|
byok,
|
|
116
|
-
hash: 'hash' in raw ? (raw as ListData).hash : 'unknown',
|
|
117
131
|
disabled: 'disabled' in raw ? (raw as ListData).disabled : false,
|
|
118
132
|
};
|
|
119
133
|
|
|
134
|
+
if (hash !== undefined) {
|
|
135
|
+
normalized.hash = hash;
|
|
136
|
+
}
|
|
120
137
|
if (limit !== undefined) {
|
|
121
138
|
normalized.limit = limit;
|
|
122
139
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robhowley/pi-openrouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Live OpenRouter spend/account TUI overlays, user-scoped model sync, and session tagging for Pi.",
|
|
5
|
+
"description": "Live OpenRouter spend/account TUI overlays, user-scoped model sync, api key management, and session tagging for Pi.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"files": [
|
|
8
8
|
"extensions",
|