@robhowley/pi-openrouter 0.8.3 → 0.9.1
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 +87 -4
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
- package/extensions/openrouter/__tests__/index.test.ts +163 -0
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +229 -0
- package/extensions/openrouter/index.ts +13 -589
- package/extensions/openrouter/local-usage.ts +145 -22
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +60 -77
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/overrides.ts +174 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +31 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/types.ts +28 -9
- package/package.json +1 -1
|
@@ -1,596 +1,20 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
2
|
-
import
|
|
3
|
-
import { MS_PER_MINUTE } from './models/types.js';
|
|
1
|
+
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
|
|
2
|
+
import { registerOpenRouterCommands } from './commands.js';
|
|
4
3
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import { UsageOverlayComponent } from './overlay.js';
|
|
12
|
-
import { formatSessionId, isOpenRouterRequest, type OpenRouterSessionState } from './session.js';
|
|
13
|
-
import { writeLocalUsage, type LocalUsageEvent } from './local-usage.js';
|
|
14
|
-
import { AccountOverlayComponent } from './account-overlay.js';
|
|
15
|
-
import { computeRollupStatus, sortKeys } from './account-format.js';
|
|
16
|
-
import { getAllKeys, getCurrentKey, getAccountCredits } from './account-client.js';
|
|
17
|
-
import type { KeyInfo } from './account-types.js';
|
|
18
|
-
import type { RollupStatus } from './account-types.js';
|
|
19
|
-
import crypto from 'node:crypto';
|
|
4
|
+
addSessionIdToOpenRouterRequest,
|
|
5
|
+
getCurrentSessionId,
|
|
6
|
+
initializeSessionState,
|
|
7
|
+
installOpenRouterHooks,
|
|
8
|
+
loadStartupCacheState,
|
|
9
|
+
} from './hooks.js';
|
|
20
10
|
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
syncModels,
|
|
24
|
-
getSyncState,
|
|
25
|
-
isSyncEnabled,
|
|
26
|
-
getSkipReasonsAsync,
|
|
27
|
-
groupSkipReasons,
|
|
28
|
-
} from './models/sync.js';
|
|
29
|
-
import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
|
|
30
|
-
import { mapOpenRouterModels } from './models/mapper.js';
|
|
31
|
-
|
|
32
|
-
// Store the current session state for use in command handlers
|
|
33
|
-
let currentSessionState: OpenRouterSessionState | null = null;
|
|
34
|
-
let sessionTrackingInstalled = false;
|
|
35
|
-
|
|
36
|
-
// Store startup cache state for notifications
|
|
37
|
-
let startupCacheInfo: { count: number; age: string } | undefined;
|
|
38
|
-
|
|
39
|
-
// =============================================================================
|
|
40
|
-
// Utility Functions
|
|
41
|
-
// =============================================================================
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Format skipped models details for --skipped flag output.
|
|
45
|
-
*/
|
|
46
|
-
function formatSkippedDetails(
|
|
47
|
-
skipCount: number,
|
|
48
|
-
groupedReasons: Record<string, number>,
|
|
49
|
-
skipReasons: Array<{ id: string; reason: string }>,
|
|
50
|
-
): string {
|
|
51
|
-
if (skipCount === 0) {
|
|
52
|
-
return '\n\nNo skipped models';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let details = `\n\nOpenRouter skipped models: ${skipCount}\n`;
|
|
56
|
-
for (const [reason, count] of Object.entries(groupedReasons)) {
|
|
57
|
-
details += `\n${count} ${reason}\n`;
|
|
58
|
-
const modelsWithReason = skipReasons.filter((r) => r.reason === reason).map((r) => r.id);
|
|
59
|
-
for (const id of modelsWithReason) {
|
|
60
|
-
details += `- ${id}\n`;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return details;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// =============================================================================
|
|
67
|
-
// Session State Management
|
|
68
|
-
// =============================================================================
|
|
69
|
-
|
|
70
|
-
function getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string {
|
|
71
|
-
if (currentSessionState) {
|
|
72
|
-
return currentSessionState.sessionId;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
77
|
-
let formattedSessionId: string;
|
|
78
|
-
if (sessionId && sessionId !== '') {
|
|
79
|
-
formattedSessionId = formatSessionId(sessionId);
|
|
80
|
-
} else {
|
|
81
|
-
formattedSessionId = formatSessionId(crypto.randomUUID());
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
currentSessionState = { sessionId: formattedSessionId };
|
|
85
|
-
return formattedSessionId;
|
|
86
|
-
} catch {
|
|
87
|
-
// Generate fallback on any error
|
|
88
|
-
const fallbackId = formatSessionId(crypto.randomUUID());
|
|
89
|
-
currentSessionState = { sessionId: fallbackId };
|
|
90
|
-
return fallbackId;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
11
|
+
export { addSessionIdToOpenRouterRequest, getCurrentSessionId };
|
|
93
12
|
|
|
94
13
|
export default async function (pi: ExtensionAPI) {
|
|
95
|
-
|
|
96
|
-
startupCacheInfo = undefined;
|
|
97
|
-
let startupCacheWarning: string | undefined;
|
|
98
|
-
if (isSyncEnabled()) {
|
|
99
|
-
const cache = await loadCache().catch(() => null);
|
|
100
|
-
|
|
101
|
-
if (cache?.models.length) {
|
|
102
|
-
try {
|
|
103
|
-
const { configs } = await mapOpenRouterModels(cache.models);
|
|
104
|
-
|
|
105
|
-
// Register models directly with Pi's OpenRouter provider
|
|
106
|
-
pi.registerProvider('openrouter', {
|
|
107
|
-
baseUrl: 'https://openrouter.ai/api/v1',
|
|
108
|
-
apiKey: 'OPENROUTER_API_KEY',
|
|
109
|
-
api: 'openai-completions',
|
|
110
|
-
models: configs,
|
|
111
|
-
authHeader: true,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Store for session_start notification
|
|
115
|
-
const age = formatDuration(getCacheAgeMs(cache));
|
|
116
|
-
startupCacheInfo = { count: configs.length, age };
|
|
117
|
-
} catch (error) {
|
|
118
|
-
startupCacheInfo = undefined;
|
|
119
|
-
startupCacheWarning = `OpenRouter: cached models found but failed to register: ${error instanceof Error ? error.message : String(error)}`;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Install before_provider_request hook once
|
|
125
|
-
if (!sessionTrackingInstalled) {
|
|
126
|
-
sessionTrackingInstalled = true;
|
|
127
|
-
|
|
128
|
-
pi.on('before_provider_request', (event, ctx) => {
|
|
129
|
-
try {
|
|
130
|
-
// Validate the payload exists
|
|
131
|
-
const ev = event as unknown as Record<string, unknown>;
|
|
132
|
-
const payload = ev['payload'] as Record<string, unknown> | undefined;
|
|
133
|
-
if (!payload) {
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Check if this is an OpenRouter request
|
|
138
|
-
const isOpenRouter = isOpenRouterRequest(event, ctx);
|
|
139
|
-
if (!isOpenRouter) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Do not overwrite existing session_id
|
|
144
|
-
if ('session_id' in payload && payload['session_id'] !== undefined) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Add session_id to the payload (OpenRouter-specific field)
|
|
149
|
-
return {
|
|
150
|
-
...payload,
|
|
151
|
-
session_id: getCurrentSessionId(ctx),
|
|
152
|
-
};
|
|
153
|
-
} catch {
|
|
154
|
-
// Fail open - silently ignore errors
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Hook turn_end to capture completed OpenRouter turns for local logging
|
|
161
|
-
pi.on('turn_end', async (event, ctx) => {
|
|
162
|
-
try {
|
|
163
|
-
const turnEvent = event as unknown as Record<string, unknown>;
|
|
164
|
-
|
|
165
|
-
const message = turnEvent['message'] as Record<string, unknown> | undefined;
|
|
166
|
-
if (!message) return;
|
|
167
|
-
|
|
168
|
-
// Check if this is an OpenRouter request based on the message content/model
|
|
169
|
-
// Include url/endpoint from turnEvent so isOpenRouterRequest can check them
|
|
170
|
-
const isOpenRouter = isOpenRouterRequest(
|
|
171
|
-
{
|
|
172
|
-
type: 'before_provider_request',
|
|
173
|
-
payload: message,
|
|
174
|
-
url: turnEvent['url'],
|
|
175
|
-
endpoint: turnEvent['endpoint'],
|
|
176
|
-
} as unknown as Parameters<typeof isOpenRouterRequest>[0],
|
|
177
|
-
ctx,
|
|
178
|
-
);
|
|
179
|
-
if (!isOpenRouter) return;
|
|
180
|
-
|
|
181
|
-
// Check if the message has usage data
|
|
182
|
-
const usage = (message as { usage?: unknown })['usage'] as
|
|
183
|
-
| {
|
|
184
|
-
input?: number;
|
|
185
|
-
output?: number;
|
|
186
|
-
cacheRead?: number;
|
|
187
|
-
cacheWrite?: number;
|
|
188
|
-
totalTokens?: number;
|
|
189
|
-
cost?: {
|
|
190
|
-
input?: number;
|
|
191
|
-
output?: number;
|
|
192
|
-
cacheRead?: number;
|
|
193
|
-
cacheWrite?: number;
|
|
194
|
-
total?: number;
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
| undefined;
|
|
198
|
-
if (!usage) return;
|
|
199
|
-
|
|
200
|
-
// Extract model from the message
|
|
201
|
-
const model = message['model'] as string | undefined;
|
|
202
|
-
const responseModel = message['responseModel'] as string | undefined;
|
|
203
|
-
const modelToLog = model || responseModel;
|
|
204
|
-
|
|
205
|
-
// Calculate total cost from usage.cost.total
|
|
206
|
-
const totalCost = usage.cost?.total;
|
|
207
|
-
|
|
208
|
-
const localEvent: LocalUsageEvent = {
|
|
209
|
-
id: crypto.randomUUID(),
|
|
210
|
-
generationId: String(message['responseId'] ?? ''),
|
|
211
|
-
sessionId: getCurrentSessionId(ctx),
|
|
212
|
-
completedAt: new Date().toISOString(),
|
|
213
|
-
model: modelToLog ?? 'unknown',
|
|
214
|
-
requests: 1,
|
|
215
|
-
promptTokens: usage.input ?? 0,
|
|
216
|
-
completionTokens: usage.output ?? 0,
|
|
217
|
-
reasoningTokens: 0,
|
|
218
|
-
cacheReadTokens: usage.cacheRead ?? 0,
|
|
219
|
-
cacheWriteTokens: usage.cacheWrite ?? 0,
|
|
220
|
-
cost: totalCost ?? 0,
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
// Write to local JSONL - fail open (don't throw)
|
|
224
|
-
writeLocalUsage(localEvent).catch(() => {});
|
|
225
|
-
} catch {
|
|
226
|
-
// Fail open - silently ignore errors
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
pi.on('session_shutdown', () => {
|
|
231
|
-
stopBackgroundRefresh();
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
// Notify on first session start after extension load
|
|
235
|
-
pi.on('session_start', (event, ctx) => {
|
|
236
|
-
if (!ctx.hasUI) return;
|
|
237
|
-
|
|
238
|
-
// Show a persistent status indicator
|
|
239
|
-
if (startupCacheInfo) {
|
|
240
|
-
const statusText = `OpenRouter ${startupCacheInfo.count} models`;
|
|
241
|
-
ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusText));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Show a one-time notification on startup
|
|
245
|
-
if (event.reason === 'startup' && startupCacheInfo) {
|
|
246
|
-
const notice = `OpenRouter: ${startupCacheInfo.count} models loaded from cache (${startupCacheInfo.age} old). Run /openrouter models-sync to refresh.`;
|
|
247
|
-
ctx.ui.notify(notice, 'info');
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (event.reason === 'startup' && startupCacheWarning) {
|
|
251
|
-
ctx.ui.notify(startupCacheWarning, 'warning');
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
pi.registerCommand('openrouter-usage', {
|
|
256
|
-
description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
|
|
257
|
-
getArgumentCompletions: () => null,
|
|
258
|
-
handler: async (args, ctx) => {
|
|
259
|
-
startBackgroundRefresh();
|
|
260
|
-
const subcommand = args.trim() || undefined;
|
|
261
|
-
await showUsageOverlay(ctx, subcommand);
|
|
262
|
-
},
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
pi.registerCommand('openrouter-session', {
|
|
266
|
-
description: 'Show the current OpenRouter session ID for request grouping',
|
|
267
|
-
getArgumentCompletions: () => null,
|
|
268
|
-
handler: async (_args, ctx) => {
|
|
269
|
-
const idToShow = getCurrentSessionId(ctx);
|
|
270
|
-
ctx.ui.notify(`OpenRouter session_id\n${idToShow}`, 'info');
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
pi.registerCommand('openrouter-account', {
|
|
275
|
-
description: 'Show OpenRouter account and key health',
|
|
276
|
-
getArgumentCompletions: () => null,
|
|
277
|
-
handler: async (_args, ctx) => {
|
|
278
|
-
await showAccountOverlay(ctx);
|
|
279
|
-
},
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
// ============== MODELS COMMANDS (subcommands of /openrouter) ==============
|
|
283
|
-
|
|
284
|
-
// Single entry point with subcommands: /openrouter [usage|account|session|models-sync|models-status]
|
|
285
|
-
pi.registerCommand('openrouter', {
|
|
286
|
-
description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
|
|
287
|
-
getArgumentCompletions: (prefix: string) => {
|
|
288
|
-
const subcommands = ['usage', 'account', 'session', 'models-sync', 'models-status'];
|
|
289
|
-
const items = subcommands
|
|
290
|
-
.filter((s) => s.startsWith(prefix))
|
|
291
|
-
.map((s) => ({ value: s, label: s }));
|
|
292
|
-
return items.length > 0 ? items : null;
|
|
293
|
-
},
|
|
294
|
-
handler: async (args, ctx) => {
|
|
295
|
-
// Parse subcommand and flags
|
|
296
|
-
const parts = args.trim().split(/\s+/);
|
|
297
|
-
const subcommand = parts[0] || '';
|
|
298
|
-
const flags = parts.slice(1).reduce(
|
|
299
|
-
(acc, flag) => {
|
|
300
|
-
acc[flag] = true;
|
|
301
|
-
return acc;
|
|
302
|
-
},
|
|
303
|
-
{} as Record<string, boolean>,
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
switch (subcommand) {
|
|
307
|
-
case 'usage': {
|
|
308
|
-
startBackgroundRefresh();
|
|
309
|
-
await showUsageOverlay(ctx, undefined);
|
|
310
|
-
break;
|
|
311
|
-
}
|
|
312
|
-
case 'account': {
|
|
313
|
-
await showAccountOverlay(ctx);
|
|
314
|
-
break;
|
|
315
|
-
}
|
|
316
|
-
case 'session': {
|
|
317
|
-
ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
case 'models-sync': {
|
|
321
|
-
if (!isSyncEnabled()) {
|
|
322
|
-
ctx.ui.notify(
|
|
323
|
-
'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
|
|
324
|
-
'error',
|
|
325
|
-
);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const result = await syncModels(ctx);
|
|
329
|
-
|
|
330
|
-
// Display brief result using same color scheme as overlays
|
|
331
|
-
if (!result.success) {
|
|
332
|
-
let message = '';
|
|
333
|
-
if (result.source === 'cache') {
|
|
334
|
-
message = `OpenRouter models sync failed\n${result.registeredCount} registered from cache\nCache age: ${formatDuration(result.cacheAgeMs)}\nError: ${result.error}`;
|
|
335
|
-
} else {
|
|
336
|
-
message = `OpenRouter models unavailable\n0 registered\nError: ${result.error}`;
|
|
337
|
-
}
|
|
338
|
-
ctx.ui.notify(message, result.source === 'cache' ? 'warning' : 'error');
|
|
339
|
-
} else {
|
|
340
|
-
const message = `OpenRouter models synced\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache updated`;
|
|
341
|
-
ctx.ui.notify(message, 'info');
|
|
342
|
-
}
|
|
343
|
-
break;
|
|
344
|
-
}
|
|
345
|
-
case 'models-status': {
|
|
346
|
-
const state = getSyncState();
|
|
347
|
-
const skipReasons = await getSkipReasonsAsync();
|
|
348
|
-
const groupedReasons = groupSkipReasons(skipReasons);
|
|
349
|
-
|
|
350
|
-
// Get real-time cache age from disk
|
|
351
|
-
const cache = await loadCache();
|
|
352
|
-
const cacheAgeMs = cache ? getCacheAgeMs(cache) : null;
|
|
353
|
-
|
|
354
|
-
if (!state && !cache) {
|
|
355
|
-
ctx.ui.notify('OpenRouter models: not synced', 'error');
|
|
356
|
-
} else if (!state && cache) {
|
|
357
|
-
// Cache exists but no in-memory state (new Pi session)
|
|
358
|
-
const cachedCount = cache.models.length;
|
|
359
|
-
const message = `OpenRouter models cached\n${cachedCount} models in cache · age: ${formatDuration(cacheAgeMs)}\nRun '/openrouter models-sync' to register models`;
|
|
360
|
-
ctx.ui.notify(message, 'info');
|
|
361
|
-
} else if (state?.success) {
|
|
362
|
-
const skipCount = skipReasons.length;
|
|
363
|
-
let message = `OpenRouter models healthy\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''} · cache age: ${formatDuration(cacheAgeMs)}`;
|
|
364
|
-
|
|
365
|
-
if (flags['--skipped']) {
|
|
366
|
-
message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
|
|
367
|
-
}
|
|
368
|
-
ctx.ui.notify(message, 'info');
|
|
369
|
-
} else if (state?.source === 'cache') {
|
|
370
|
-
const skipCount = skipReasons.length;
|
|
371
|
-
let message = `OpenRouter models cached\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''}\nCache age: ${formatDuration(cacheAgeMs)}\nError: ${state.error}`;
|
|
372
|
-
|
|
373
|
-
if (flags['--skipped']) {
|
|
374
|
-
message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
|
|
375
|
-
}
|
|
376
|
-
ctx.ui.notify(message, 'warning');
|
|
377
|
-
} else {
|
|
378
|
-
ctx.ui.notify(
|
|
379
|
-
`OpenRouter models broken\n0 registered\nError: ${state?.error}`,
|
|
380
|
-
'error',
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
default: {
|
|
386
|
-
const available = ['usage', 'account', 'session', 'models-sync', 'models-status'];
|
|
387
|
-
const message =
|
|
388
|
-
available.length > 0
|
|
389
|
-
? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
|
|
390
|
-
: 'No subcommands available';
|
|
391
|
-
ctx.ui.notify(`OpenRouter subcommands\n${message}`, 'error');
|
|
392
|
-
break;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
},
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function showAccountOverlay(ctx: ExtensionContext) {
|
|
400
|
-
let error: string | null = null;
|
|
401
|
-
let keyInfo: KeyInfo[] | null = null;
|
|
402
|
-
let credits: number | null = null;
|
|
403
|
-
|
|
404
|
-
try {
|
|
405
|
-
// Try to get all keys with management key
|
|
406
|
-
const allKeys = await getAllKeys();
|
|
407
|
-
|
|
408
|
-
if (allKeys && allKeys.length > 0) {
|
|
409
|
-
keyInfo = allKeys;
|
|
410
|
-
} else {
|
|
411
|
-
// getAllKeys() returns null or empty array when management key isn't available
|
|
412
|
-
// or when the API call fails with 403
|
|
413
|
-
error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
|
|
414
|
-
|
|
415
|
-
// Fall back to current key only
|
|
416
|
-
try {
|
|
417
|
-
const currentKey = await getCurrentKey();
|
|
418
|
-
if (currentKey) {
|
|
419
|
-
keyInfo = [currentKey];
|
|
420
|
-
// Clear the error since we successfully got current key
|
|
421
|
-
error = null;
|
|
422
|
-
} else {
|
|
423
|
-
error = 'Failed to retrieve current key metadata. Check your API key permissions.';
|
|
424
|
-
}
|
|
425
|
-
} catch (err) {
|
|
426
|
-
error = `Failed to retrieve current key: ${(err as Error).message}`;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Try to get account credits
|
|
431
|
-
credits = await getAccountCredits();
|
|
432
|
-
|
|
433
|
-
// Set error if we have no keys and no credits
|
|
434
|
-
if (!keyInfo && !credits) {
|
|
435
|
-
error =
|
|
436
|
-
'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.';
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// Set error if we have credits but no keys
|
|
440
|
-
if (!keyInfo && credits !== null) {
|
|
441
|
-
error =
|
|
442
|
-
error ||
|
|
443
|
-
'Key information unavailable. Set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Compute rollup status
|
|
447
|
-
const rollupStatus = keyInfo
|
|
448
|
-
? computeRollupStatus(keyInfo)
|
|
449
|
-
: { status: 'unavailable' as const };
|
|
450
|
-
|
|
451
|
-
// Sort keys
|
|
452
|
-
if (keyInfo) {
|
|
453
|
-
const sortedKeys = sortKeys(keyInfo);
|
|
454
|
-
keyInfo = sortedKeys;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
|
|
458
|
-
} catch (error_) {
|
|
459
|
-
const err = error_ as Error;
|
|
460
|
-
error =
|
|
461
|
-
err instanceof AuthError
|
|
462
|
-
? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.'
|
|
463
|
-
: `API Error: ${err.message}`;
|
|
464
|
-
|
|
465
|
-
// Try to get current key for overlay even on error
|
|
466
|
-
try {
|
|
467
|
-
const currentKey = await getCurrentKey();
|
|
468
|
-
if (currentKey) {
|
|
469
|
-
keyInfo = [currentKey];
|
|
470
|
-
}
|
|
471
|
-
} catch {
|
|
472
|
-
// Ignore secondary errors
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const rollupStatus = keyInfo
|
|
476
|
-
? computeRollupStatus(keyInfo)
|
|
477
|
-
: { status: 'unavailable' as const };
|
|
478
|
-
|
|
479
|
-
await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
async function showAccountOverlayComponent(
|
|
484
|
-
ctx: ExtensionContext,
|
|
485
|
-
keyInfo: KeyInfo[] | null,
|
|
486
|
-
credits: number | null,
|
|
487
|
-
rollupStatus: RollupStatus,
|
|
488
|
-
error: string | null,
|
|
489
|
-
) {
|
|
490
|
-
await ctx.ui.custom<void>(
|
|
491
|
-
(_tui, theme, _keybindings, done) => {
|
|
492
|
-
const overlayComponent = new AccountOverlayComponent(
|
|
493
|
-
keyInfo,
|
|
494
|
-
credits,
|
|
495
|
-
rollupStatus,
|
|
496
|
-
error,
|
|
497
|
-
theme,
|
|
498
|
-
done,
|
|
499
|
-
() => _tui.requestRender(),
|
|
500
|
-
ctx,
|
|
501
|
-
);
|
|
502
|
-
|
|
503
|
-
return {
|
|
504
|
-
handleInput: (data: string) => {
|
|
505
|
-
overlayComponent.handleInput(data);
|
|
506
|
-
_tui.requestRender();
|
|
507
|
-
},
|
|
508
|
-
render: (width: number) => overlayComponent.render(width),
|
|
509
|
-
invalidate: () => overlayComponent.invalidate(),
|
|
510
|
-
dispose: () => {
|
|
511
|
-
overlayComponent.dispose();
|
|
512
|
-
},
|
|
513
|
-
wantsKeyRelease: false,
|
|
514
|
-
};
|
|
515
|
-
},
|
|
516
|
-
{
|
|
517
|
-
overlay: true,
|
|
518
|
-
overlayOptions: {
|
|
519
|
-
width: 100,
|
|
520
|
-
},
|
|
521
|
-
},
|
|
522
|
-
);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
|
|
526
|
-
const cachedSummary = usageCache.get('usage');
|
|
527
|
-
const lastFetchTimestamp = usageCache.getTimestamp('usage');
|
|
528
|
-
const cachedMinutesAgo = lastFetchTimestamp
|
|
529
|
-
? Math.round((Date.now() - lastFetchTimestamp) / MS_PER_MINUTE)
|
|
530
|
-
: null;
|
|
531
|
-
|
|
532
|
-
if (cachedSummary) {
|
|
533
|
-
await showOverlay(ctx, cachedSummary, null, cachedMinutesAgo);
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
let error: string | null = null;
|
|
538
|
-
let summary: UsageSummary | null = null;
|
|
539
|
-
|
|
540
|
-
try {
|
|
541
|
-
summary = await fetchAndAggregate();
|
|
542
|
-
if (!summary) {
|
|
543
|
-
error =
|
|
544
|
-
'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.';
|
|
545
|
-
} else {
|
|
546
|
-
usageCache.set('usage', summary);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
await showOverlay(ctx, summary, error, 0);
|
|
550
|
-
} catch (error_) {
|
|
551
|
-
const err = error_ as Error;
|
|
552
|
-
error =
|
|
553
|
-
err instanceof AuthError
|
|
554
|
-
? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.'
|
|
555
|
-
: `API Error: ${err.message}`;
|
|
556
|
-
await showOverlay(ctx, null, error, cachedMinutesAgo || 0);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
14
|
+
initializeSessionState();
|
|
559
15
|
|
|
560
|
-
|
|
561
|
-
ctx: ExtensionContext,
|
|
562
|
-
summary: UsageSummary | null,
|
|
563
|
-
error: string | null,
|
|
564
|
-
cachedMinutesAgo: number | null,
|
|
565
|
-
) {
|
|
566
|
-
await ctx.ui.custom<void>(
|
|
567
|
-
(_tui, theme, _keybindings, done) => {
|
|
568
|
-
const overlayComponent = new UsageOverlayComponent(
|
|
569
|
-
summary,
|
|
570
|
-
error,
|
|
571
|
-
cachedMinutesAgo,
|
|
572
|
-
theme,
|
|
573
|
-
done,
|
|
574
|
-
() => _tui.requestRender(),
|
|
575
|
-
);
|
|
16
|
+
const startupState = await loadStartupCacheState(pi);
|
|
576
17
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
overlayComponent.handleInput(data);
|
|
580
|
-
_tui.requestRender();
|
|
581
|
-
},
|
|
582
|
-
render: (width: number) => overlayComponent.render(width),
|
|
583
|
-
invalidate: () => overlayComponent.invalidate(),
|
|
584
|
-
dispose: () => {
|
|
585
|
-
overlayComponent.dispose();
|
|
586
|
-
},
|
|
587
|
-
};
|
|
588
|
-
},
|
|
589
|
-
{
|
|
590
|
-
overlay: true,
|
|
591
|
-
overlayOptions: {
|
|
592
|
-
width: 100,
|
|
593
|
-
},
|
|
594
|
-
},
|
|
595
|
-
);
|
|
18
|
+
installOpenRouterHooks(pi, startupState);
|
|
19
|
+
registerOpenRouterCommands(pi);
|
|
596
20
|
}
|