@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
|
@@ -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 {
|
|
@@ -10,9 +10,13 @@ import { writeLocalUsage, type LocalUsageEvent } from './local-usage.js';
|
|
|
10
10
|
import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
|
|
11
11
|
import { mapOpenRouterModels } from './models/mapper.js';
|
|
12
12
|
import { includeBuiltinRouterModels, isSyncEnabled } from './models/sync.js';
|
|
13
|
+
import { loadOpenRouterStatusBar } from './status-bar.js';
|
|
13
14
|
|
|
14
15
|
let sessionState: SessionState | null = null;
|
|
15
16
|
let sessionTrackingInstalled = false;
|
|
17
|
+
let openRouterStatusRolloverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
+
|
|
19
|
+
type StatusContext = Pick<ExtensionContext, 'hasUI' | 'ui'>;
|
|
16
20
|
|
|
17
21
|
export interface StartupCacheState {
|
|
18
22
|
info?: {
|
|
@@ -129,15 +133,12 @@ function installSessionTaggingHook(pi: Pick<ExtensionAPI, 'on'>): void {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
function installLocalUsageHook(pi: Pick<ExtensionAPI, 'on'>): void {
|
|
132
|
-
pi.on('turn_end',
|
|
133
|
-
|
|
136
|
+
pi.on('turn_end', (event, ctx) => {
|
|
137
|
+
captureLocalUsage(event as unknown, ctx);
|
|
134
138
|
});
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
|
|
138
|
-
event: unknown,
|
|
139
|
-
ctx: { sessionManager: { getSessionId(): string } },
|
|
140
|
-
): Promise<void> {
|
|
141
|
+
function captureLocalUsage(event: unknown, ctx: ExtensionContext): void {
|
|
141
142
|
try {
|
|
142
143
|
const turnEvent = event as Record<string, unknown>;
|
|
143
144
|
|
|
@@ -184,17 +185,77 @@ async function captureLocalUsage(
|
|
|
184
185
|
cost: usage.cost?.total ?? 0,
|
|
185
186
|
};
|
|
186
187
|
|
|
187
|
-
writeLocalUsage(localEvent)
|
|
188
|
+
void writeLocalUsage(localEvent)
|
|
189
|
+
.then(() => refreshOpenRouterUsageStatus(ctx))
|
|
190
|
+
.catch(() => {});
|
|
188
191
|
} catch {
|
|
189
192
|
// Fail open - silently ignore errors
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
195
|
|
|
196
|
+
async function refreshOpenRouterUsageStatus(ctx: StatusContext): Promise<void> {
|
|
197
|
+
if (!ctx.hasUI) return;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const statusResult = await loadOpenRouterStatusBar();
|
|
201
|
+
|
|
202
|
+
switch (statusResult.kind) {
|
|
203
|
+
case 'ready':
|
|
204
|
+
ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusResult.text));
|
|
205
|
+
return;
|
|
206
|
+
case 'empty':
|
|
207
|
+
ctx.ui.setStatus('openrouter', undefined);
|
|
208
|
+
return;
|
|
209
|
+
case 'failed':
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Fail open - preserve the existing status on unexpected refresh errors.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function clearOpenRouterStatusRolloverTimer(): void {
|
|
218
|
+
if (openRouterStatusRolloverTimer !== null) {
|
|
219
|
+
clearTimeout(openRouterStatusRolloverTimer);
|
|
220
|
+
openRouterStatusRolloverTimer = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getMillisecondsUntilNextUtcMidnight(now: Date = new Date()): number {
|
|
225
|
+
const nextUtcMidnight = Date.UTC(
|
|
226
|
+
now.getUTCFullYear(),
|
|
227
|
+
now.getUTCMonth(),
|
|
228
|
+
now.getUTCDate() + 1,
|
|
229
|
+
0,
|
|
230
|
+
0,
|
|
231
|
+
0,
|
|
232
|
+
0,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return Math.max(0, nextUtcMidnight - now.getTime());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function scheduleOpenRouterStatusRollover(ctx: StatusContext): void {
|
|
239
|
+
clearOpenRouterStatusRolloverTimer();
|
|
240
|
+
|
|
241
|
+
if (!ctx.hasUI) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
openRouterStatusRolloverTimer = setTimeout(() => {
|
|
246
|
+
openRouterStatusRolloverTimer = null;
|
|
247
|
+
void refreshOpenRouterUsageStatus(ctx).catch(() => {});
|
|
248
|
+
scheduleOpenRouterStatusRollover(ctx);
|
|
249
|
+
}, getMillisecondsUntilNextUtcMidnight());
|
|
250
|
+
openRouterStatusRolloverTimer.unref?.();
|
|
251
|
+
}
|
|
252
|
+
|
|
193
253
|
function installLifecycleHooks(
|
|
194
254
|
pi: Pick<ExtensionAPI, 'on'>,
|
|
195
255
|
startupState: StartupCacheState,
|
|
196
256
|
): void {
|
|
197
257
|
pi.on('session_shutdown', () => {
|
|
258
|
+
clearOpenRouterStatusRolloverTimer();
|
|
198
259
|
stopBackgroundRefresh();
|
|
199
260
|
sessionState?.reset();
|
|
200
261
|
});
|
|
@@ -213,10 +274,8 @@ function handleSessionStart(
|
|
|
213
274
|
|
|
214
275
|
if (!ctx.hasUI) return;
|
|
215
276
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusText));
|
|
219
|
-
}
|
|
277
|
+
void refreshOpenRouterUsageStatus(ctx).catch(() => {});
|
|
278
|
+
scheduleOpenRouterStatusRollover(ctx);
|
|
220
279
|
|
|
221
280
|
if (event.reason === 'startup' && startupState.info) {
|
|
222
281
|
const notice = `OpenRouter: ${startupState.info.count} models loaded from cache (${startupState.info.age} old). Run /openrouter models-sync to refresh.`;
|
|
@@ -165,15 +165,9 @@ export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<Lo
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
|
-
*
|
|
169
|
-
* Deduplicates by id (first occurrence wins).
|
|
168
|
+
* Deduplicate local usage events by id (first occurrence wins).
|
|
170
169
|
*/
|
|
171
|
-
export function
|
|
172
|
-
if (events.length === 0) {
|
|
173
|
-
return createZeroAggregate();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Deduplicate by id
|
|
170
|
+
export function dedupeLocalUsageEvents(events: LocalUsageEvent[]): LocalUsageEvent[] {
|
|
177
171
|
const seen = new Set<string>();
|
|
178
172
|
const unique: LocalUsageEvent[] = [];
|
|
179
173
|
|
|
@@ -183,8 +177,19 @@ export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
|
|
|
183
177
|
unique.push(event);
|
|
184
178
|
}
|
|
185
179
|
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
return unique;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Aggregate local usage events into UsageAggregate.
|
|
185
|
+
* Deduplicates by id (first occurrence wins).
|
|
186
|
+
*/
|
|
187
|
+
export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
|
|
188
|
+
if (events.length === 0) {
|
|
189
|
+
return createZeroAggregate();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const result = dedupeLocalUsageEvents(events).reduce((acc, event) => {
|
|
188
193
|
acc.requests += event.requests ?? 1;
|
|
189
194
|
acc.promptTokens += event.promptTokens || 0;
|
|
190
195
|
acc.completionTokens += event.completionTokens || 0;
|