@robhowley/pi-openrouter 0.9.0 → 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.
Files changed (37) hide show
  1. package/README.md +39 -4
  2. package/extensions/openrouter/__tests__/cache.test.ts +769 -0
  3. package/extensions/openrouter/__tests__/client.test.ts +333 -15
  4. package/extensions/openrouter/__tests__/commands.test.ts +816 -0
  5. package/extensions/openrouter/__tests__/fixtures.ts +140 -1
  6. package/extensions/openrouter/__tests__/format.test.ts +19 -0
  7. package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
  8. package/extensions/openrouter/__tests__/index.test.ts +112 -363
  9. package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
  10. package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
  11. package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
  12. package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
  13. package/extensions/openrouter/__tests__/session.test.ts +44 -43
  14. package/extensions/openrouter/account-client.ts +11 -61
  15. package/extensions/openrouter/cache.ts +203 -91
  16. package/extensions/openrouter/client.ts +49 -3
  17. package/extensions/openrouter/commands.ts +555 -0
  18. package/extensions/openrouter/format.ts +7 -4
  19. package/extensions/openrouter/hooks.ts +229 -0
  20. package/extensions/openrouter/index.ts +13 -990
  21. package/extensions/openrouter/local-usage.ts +145 -22
  22. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  23. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  24. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  25. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  26. package/extensions/openrouter/models/cache.ts +27 -2
  27. package/extensions/openrouter/models/mapper.ts +35 -69
  28. package/extensions/openrouter/models/override-commands.ts +434 -0
  29. package/extensions/openrouter/models/skip-hints.ts +19 -0
  30. package/extensions/openrouter/models/sync.ts +22 -10
  31. package/extensions/openrouter/models/types.ts +2 -1
  32. package/extensions/openrouter/normalizers.ts +128 -0
  33. package/extensions/openrouter/overlay.ts +19 -8
  34. package/extensions/openrouter/session-state.ts +110 -0
  35. package/extensions/openrouter/session.ts +16 -0
  36. package/extensions/openrouter/types.ts +28 -9
  37. package/package.json +1 -1
@@ -1,997 +1,20 @@
1
- import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
2
- import type { UsageSummary } from './types.js';
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
- usageCache,
6
- startBackgroundRefresh,
7
- stopBackgroundRefresh,
8
- fetchAndAggregate,
9
- } from './cache.js';
10
- import { AuthError } from './client.js';
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
- // Import models sync
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
- import {
32
- loadModelOverrides,
33
- saveModelOverrides,
34
- setModelOverride,
35
- removeModelOverride,
36
- getModelOverride,
37
- getOverrideModelIds,
38
- hasOverrides,
39
- } from './models/overrides.js';
40
- import type { UserModelOverride, ThinkingLevelMap, ModelOverridesFile } from './models/types.js';
41
-
42
- // Store the current session state for use in command handlers
43
- let currentSessionState: OpenRouterSessionState | null = null;
44
- let sessionTrackingInstalled = false;
45
-
46
- // Store startup cache state for notifications
47
- let startupCacheInfo: { count: number; age: string } | undefined;
48
-
49
- // =============================================================================
50
- // Utility Functions
51
- // =============================================================================
52
-
53
- /**
54
- * Format skipped models details for --skipped flag output.
55
- */
56
- function getErrorMessage(error: unknown): string {
57
- return error instanceof Error ? error.message : String(error);
58
- }
59
-
60
- function formatSkippedDetails(
61
- skipCount: number,
62
- groupedReasons: Record<string, number>,
63
- skipReasons: Array<{ id: string; reason: string }>,
64
- ): string {
65
- if (skipCount === 0) {
66
- return '\n\nNo skipped models';
67
- }
68
-
69
- let details = `\n\nOpenRouter skipped models: ${skipCount}\n`;
70
- for (const [reason, count] of Object.entries(groupedReasons)) {
71
- details += `\n${count} ${reason}\n`;
72
- const modelsWithReason = skipReasons.filter((r) => r.reason === reason).map((r) => r.id);
73
- for (const id of modelsWithReason) {
74
- details += `- ${id}\n`;
75
- }
76
- }
77
- return details;
78
- }
79
-
80
- // =============================================================================
81
- // Session State Management
82
- // =============================================================================
83
-
84
- function getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string {
85
- if (currentSessionState) {
86
- return currentSessionState.sessionId;
87
- }
88
-
89
- try {
90
- const sessionId = ctx.sessionManager.getSessionId();
91
- let formattedSessionId: string;
92
- if (sessionId && sessionId !== '') {
93
- formattedSessionId = formatSessionId(sessionId);
94
- } else {
95
- formattedSessionId = formatSessionId(crypto.randomUUID());
96
- }
97
-
98
- currentSessionState = { sessionId: formattedSessionId };
99
- return formattedSessionId;
100
- } catch {
101
- // Generate fallback on any error
102
- const fallbackId = formatSessionId(crypto.randomUUID());
103
- currentSessionState = { sessionId: fallbackId };
104
- return fallbackId;
105
- }
106
- }
11
+ export { addSessionIdToOpenRouterRequest, getCurrentSessionId };
107
12
 
108
13
  export default async function (pi: ExtensionAPI) {
109
- // Eager cache load on extension startup (before any sessions)
110
- startupCacheInfo = undefined;
111
- let startupCacheWarning: string | undefined;
112
- if (isSyncEnabled()) {
113
- const cache = await loadCache().catch(() => null);
114
-
115
- if (cache?.models.length) {
116
- try {
117
- const { configs } = await mapOpenRouterModels(cache.models);
118
-
119
- // Register models directly with Pi's OpenRouter provider
120
- pi.registerProvider('openrouter', {
121
- baseUrl: 'https://openrouter.ai/api/v1',
122
- apiKey: 'OPENROUTER_API_KEY',
123
- api: 'openai-completions',
124
- models: configs,
125
- authHeader: true,
126
- });
127
-
128
- // Store for session_start notification
129
- const age = formatDuration(getCacheAgeMs(cache));
130
- startupCacheInfo = { count: configs.length, age };
131
- } catch (error) {
132
- startupCacheInfo = undefined;
133
- startupCacheWarning = `OpenRouter: cached models found but failed to register: ${error instanceof Error ? error.message : String(error)}`;
134
- }
135
- }
136
- }
137
-
138
- // Install before_provider_request hook once
139
- if (!sessionTrackingInstalled) {
140
- sessionTrackingInstalled = true;
141
-
142
- pi.on('before_provider_request', (event, ctx) => {
143
- try {
144
- // Validate the payload exists
145
- const ev = event as unknown as Record<string, unknown>;
146
- const payload = ev['payload'] as Record<string, unknown> | undefined;
147
- if (!payload) {
148
- return;
149
- }
150
-
151
- // Check if this is an OpenRouter request
152
- const isOpenRouter = isOpenRouterRequest(event, ctx);
153
- if (!isOpenRouter) {
154
- return;
155
- }
156
-
157
- // Do not overwrite existing session_id
158
- if ('session_id' in payload && payload['session_id'] !== undefined) {
159
- return;
160
- }
161
-
162
- // Add session_id to the payload (OpenRouter-specific field)
163
- return {
164
- ...payload,
165
- session_id: getCurrentSessionId(ctx),
166
- };
167
- } catch {
168
- // Fail open - silently ignore errors
169
- return;
170
- }
171
- });
172
- }
173
-
174
- // Hook turn_end to capture completed OpenRouter turns for local logging
175
- pi.on('turn_end', async (event, ctx) => {
176
- try {
177
- const turnEvent = event as unknown as Record<string, unknown>;
178
-
179
- const message = turnEvent['message'] as Record<string, unknown> | undefined;
180
- if (!message) return;
181
-
182
- // Check if this is an OpenRouter request based on the message content/model
183
- // Include url/endpoint from turnEvent so isOpenRouterRequest can check them
184
- const isOpenRouter = isOpenRouterRequest(
185
- {
186
- type: 'before_provider_request',
187
- payload: message,
188
- url: turnEvent['url'],
189
- endpoint: turnEvent['endpoint'],
190
- } as unknown as Parameters<typeof isOpenRouterRequest>[0],
191
- ctx,
192
- );
193
- if (!isOpenRouter) return;
194
-
195
- // Check if the message has usage data
196
- const usage = (message as { usage?: unknown })['usage'] as
197
- | {
198
- input?: number;
199
- output?: number;
200
- cacheRead?: number;
201
- cacheWrite?: number;
202
- totalTokens?: number;
203
- cost?: {
204
- input?: number;
205
- output?: number;
206
- cacheRead?: number;
207
- cacheWrite?: number;
208
- total?: number;
209
- };
210
- }
211
- | undefined;
212
- if (!usage) return;
213
-
214
- // Extract model from the message
215
- const model = message['model'] as string | undefined;
216
- const responseModel = message['responseModel'] as string | undefined;
217
- const modelToLog = model || responseModel;
218
-
219
- // Calculate total cost from usage.cost.total
220
- const totalCost = usage.cost?.total;
221
-
222
- const localEvent: LocalUsageEvent = {
223
- id: crypto.randomUUID(),
224
- generationId: String(message['responseId'] ?? ''),
225
- sessionId: getCurrentSessionId(ctx),
226
- completedAt: new Date().toISOString(),
227
- model: modelToLog ?? 'unknown',
228
- requests: 1,
229
- promptTokens: usage.input ?? 0,
230
- completionTokens: usage.output ?? 0,
231
- reasoningTokens: 0,
232
- cacheReadTokens: usage.cacheRead ?? 0,
233
- cacheWriteTokens: usage.cacheWrite ?? 0,
234
- cost: totalCost ?? 0,
235
- };
236
-
237
- // Write to local JSONL - fail open (don't throw)
238
- writeLocalUsage(localEvent).catch(() => {});
239
- } catch {
240
- // Fail open - silently ignore errors
241
- }
242
- });
243
-
244
- pi.on('session_shutdown', () => {
245
- stopBackgroundRefresh();
246
- });
247
-
248
- // Notify on first session start after extension load
249
- pi.on('session_start', (event, ctx) => {
250
- if (!ctx.hasUI) return;
251
-
252
- // Show a persistent status indicator
253
- if (startupCacheInfo) {
254
- const statusText = `OpenRouter ${startupCacheInfo.count} models`;
255
- ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusText));
256
- }
257
-
258
- // Show a one-time notification on startup
259
- if (event.reason === 'startup' && startupCacheInfo) {
260
- const notice = `OpenRouter: ${startupCacheInfo.count} models loaded from cache (${startupCacheInfo.age} old). Run /openrouter models-sync to refresh.`;
261
- ctx.ui.notify(notice, 'info');
262
- }
263
-
264
- if (event.reason === 'startup' && startupCacheWarning) {
265
- ctx.ui.notify(startupCacheWarning, 'warning');
266
- }
267
- });
268
-
269
- pi.registerCommand('openrouter-usage', {
270
- description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
271
- getArgumentCompletions: () => null,
272
- handler: async (args, ctx) => {
273
- startBackgroundRefresh();
274
- const subcommand = args.trim() || undefined;
275
- await showUsageOverlay(ctx, subcommand);
276
- },
277
- });
278
-
279
- pi.registerCommand('openrouter-session', {
280
- description: 'Show the current OpenRouter session ID for request grouping',
281
- getArgumentCompletions: () => null,
282
- handler: async (_args, ctx) => {
283
- const idToShow = getCurrentSessionId(ctx);
284
- ctx.ui.notify(`OpenRouter session_id\n${idToShow}`, 'info');
285
- },
286
- });
287
-
288
- pi.registerCommand('openrouter-account', {
289
- description: 'Show OpenRouter account and key health',
290
- getArgumentCompletions: () => null,
291
- handler: async (_args, ctx) => {
292
- await showAccountOverlay(ctx);
293
- },
294
- });
295
-
296
- // ============== MODELS COMMANDS (subcommands of /openrouter) ==============
297
-
298
- // Single entry point with subcommands: /openrouter [usage|account|session|models-sync|models-status]
299
- pi.registerCommand('openrouter', {
300
- description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
301
- getArgumentCompletions: (prefix: string) => {
302
- const subcommands = [
303
- 'usage',
304
- 'account',
305
- 'session',
306
- 'models-sync',
307
- 'models-status',
308
- 'model-override-set',
309
- 'model-override-clear',
310
- 'model-override-list',
311
- ];
312
- const items = subcommands
313
- .filter((s) => s.startsWith(prefix))
314
- .map((s) => ({ value: s, label: s }));
315
- return items.length > 0 ? items : null;
316
- },
317
- handler: async (args, ctx) => {
318
- // Parse subcommand and args
319
- const parts = args.trim().split(/\s+/);
320
- const subcommand = parts[0] || '';
321
- const subcommandArgs = parts.slice(1).join(' ').trim();
322
- const flags = parts.slice(1).reduce(
323
- (acc, flag) => {
324
- acc[flag] = true;
325
- return acc;
326
- },
327
- {} as Record<string, boolean>,
328
- );
329
-
330
- switch (subcommand) {
331
- case 'usage': {
332
- startBackgroundRefresh();
333
- await showUsageOverlay(ctx, undefined);
334
- break;
335
- }
336
- case 'account': {
337
- await showAccountOverlay(ctx);
338
- break;
339
- }
340
- case 'session': {
341
- ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
342
- break;
343
- }
344
- case 'models-sync': {
345
- if (!isSyncEnabled()) {
346
- ctx.ui.notify(
347
- 'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
348
- 'error',
349
- );
350
- return;
351
- }
352
- const result = await syncModels(ctx);
353
-
354
- // Display brief result using same color scheme as overlays
355
- if (!result.success) {
356
- let message = '';
357
- if (result.source === 'cache') {
358
- message = `OpenRouter models sync failed\n${result.registeredCount} registered from cache\nCache age: ${formatDuration(result.cacheAgeMs)}\nError: ${result.error}`;
359
- } else {
360
- message = `OpenRouter models unavailable\n0 registered\nError: ${result.error}`;
361
- }
362
- ctx.ui.notify(message, result.source === 'cache' ? 'warning' : 'error');
363
- } else {
364
- const message = `OpenRouter models synced\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache updated`;
365
- ctx.ui.notify(message, 'info');
366
- }
367
- break;
368
- }
369
- case 'models-status': {
370
- const state = getSyncState();
371
- const skipReasons = await getSkipReasonsAsync();
372
- const groupedReasons = groupSkipReasons(skipReasons);
373
-
374
- // Get real-time cache age from disk
375
- const cache = await loadCache();
376
- const cacheAgeMs = cache ? getCacheAgeMs(cache) : null;
377
-
378
- if (!state && !cache) {
379
- ctx.ui.notify('OpenRouter models: not synced', 'error');
380
- } else if (!state && cache) {
381
- // Cache exists but no in-memory state (new Pi session)
382
- const cachedCount = cache.models.length;
383
- const message = `OpenRouter models cached\n${cachedCount} models in cache · age: ${formatDuration(cacheAgeMs)}\nRun '/openrouter models-sync' to register models`;
384
- ctx.ui.notify(message, 'info');
385
- } else if (state?.success) {
386
- const skipCount = skipReasons.length;
387
- let message = `OpenRouter models healthy\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''} · cache age: ${formatDuration(cacheAgeMs)}`;
388
-
389
- if (flags['--skipped']) {
390
- message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
391
- }
392
- ctx.ui.notify(message, 'info');
393
- } else if (state?.source === 'cache') {
394
- const skipCount = skipReasons.length;
395
- let message = `OpenRouter models cached\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''}\nCache age: ${formatDuration(cacheAgeMs)}\nError: ${state.error}`;
396
-
397
- if (flags['--skipped']) {
398
- message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
399
- }
400
- ctx.ui.notify(message, 'warning');
401
- } else {
402
- ctx.ui.notify(
403
- `OpenRouter models broken\n0 registered\nError: ${state?.error}`,
404
- 'error',
405
- );
406
- }
407
- break;
408
- }
409
- case 'model-override-set': {
410
- let userOverrides: ModelOverridesFile;
411
- try {
412
- userOverrides = await loadModelOverrides();
413
- } catch (error) {
414
- ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
415
- break;
416
- }
417
-
418
- const result = await handleModelOverrideSet(subcommandArgs, userOverrides);
419
- if (result.success) {
420
- ctx.ui.notify(result.message, 'info');
421
- // Notify if we just changed the currently active model
422
- if (result.modelId && ctx.model && result.modelId === ctx.model.id) {
423
- ctx.ui.notify(
424
- 'Model configuration updated. Run /openrouter models-sync to apply changes to the current conversation.',
425
- 'info',
426
- );
427
- }
428
- } else {
429
- ctx.ui.notify(result.message, 'error');
430
- }
431
- break;
432
- }
433
- case 'model-override-clear': {
434
- let userOverrides: ModelOverridesFile;
435
- try {
436
- userOverrides = await loadModelOverrides();
437
- } catch (error) {
438
- ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
439
- break;
440
- }
441
-
442
- const result = await handleModelOverrideClear(subcommandArgs, userOverrides);
443
- if (result.success) {
444
- ctx.ui.notify(result.message, 'info');
445
- // Notify if we just cleared the currently active model
446
- if (result.modelId && ctx.model && result.modelId === ctx.model.id) {
447
- ctx.ui.notify(
448
- 'Model configuration updated. Run /openrouter models-sync to apply changes to the current conversation.',
449
- 'info',
450
- );
451
- }
452
- } else {
453
- ctx.ui.notify(result.message, 'error');
454
- }
455
- break;
456
- }
457
- case 'model-override-list': {
458
- try {
459
- const result = await handleModelOverrideList(subcommandArgs);
460
- ctx.ui.notify(result, 'info');
461
- } catch (error) {
462
- ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
463
- }
464
- break;
465
- }
466
- default: {
467
- const available = [
468
- 'usage',
469
- 'account',
470
- 'session',
471
- 'models-sync',
472
- 'models-status',
473
- 'model-override-set',
474
- 'model-override-clear',
475
- 'model-override-list',
476
- ];
477
- const message =
478
- available.length > 0
479
- ? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
480
- : 'No subcommands available';
481
- ctx.ui.notify(`OpenRouter subcommands\n${message}`, 'error');
482
- break;
483
- }
484
- }
485
- },
486
- });
487
- }
488
-
489
- // =============================================================================
490
- // Generic Model Override Handlers (Scoped Syntax)
491
- // =============================================================================
492
-
493
- interface HandlerResult {
494
- success: boolean;
495
- message: string;
496
- modelId?: string;
497
- }
498
-
499
- interface ScopedField {
500
- targetField: string;
501
- targetType: 'string' | 'number' | 'boolean';
502
- }
503
-
504
- /**
505
- * Scoped field name mapping: converts user-facing 'thinking.X' to internal 'thinkingLevelMap.X'
506
- * Also supports exact PiModelConfig field names for future extensibility.
507
- */
508
- const SCOPED_FIELD_MAP: Record<string, ScopedField> = {
509
- // thinking.* shorthand - maps to thinkingLevelMap
510
- 'thinking.off': { targetField: 'thinkingLevelMap.off', targetType: 'string' },
511
- 'thinking.minimal': { targetField: 'thinkingLevelMap.minimal', targetType: 'string' },
512
- 'thinking.low': { targetField: 'thinkingLevelMap.low', targetType: 'string' },
513
- 'thinking.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
514
- 'thinking.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
515
- 'thinking.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
516
-
517
- // exact field names (passthrough)
518
- 'thinkingLevelMap.off': { targetField: 'thinkingLevelMap.off', targetType: 'string' },
519
- 'thinkingLevelMap.minimal': { targetField: 'thinkingLevelMap.minimal', targetType: 'string' },
520
- 'thinkingLevelMap.low': { targetField: 'thinkingLevelMap.low', targetType: 'string' },
521
- 'thinkingLevelMap.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
522
- 'thinkingLevelMap.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
523
- 'thinkingLevelMap.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
524
-
525
- // top-level fields (future extensibility)
526
- contextWindow: { targetField: 'contextWindow', targetType: 'number' },
527
- maxTokens: { targetField: 'maxTokens', targetType: 'number' },
528
- reasoning: { targetField: 'reasoning', targetType: 'boolean' },
529
- };
530
-
531
- /**
532
- * Parse a scoped assignment like "thinking.high=high" or "contextWindow=128000".
533
- */
534
- export function parseScopedAssignment(
535
- assignment: string,
536
- ): { fullPath: string; value: unknown } | null {
537
- const eqIdx = assignment.indexOf('=');
538
- if (eqIdx === -1) return null;
539
-
540
- const scopedName = assignment.slice(0, eqIdx).trim();
541
- const rawValue = assignment.slice(eqIdx + 1).trim();
542
-
543
- const mapped = SCOPED_FIELD_MAP[scopedName];
544
- if (!mapped) return null;
545
-
546
- // Parse value by type
547
- let parsedValue: unknown;
548
- switch (mapped.targetType) {
549
- case 'string':
550
- // "null" -> null, otherwise string
551
- parsedValue = rawValue === 'null' ? null : rawValue;
552
- break;
553
- case 'number': {
554
- const num = parseInt(rawValue, 10);
555
- if (isNaN(num)) return null;
556
- parsedValue = num;
557
- break;
558
- }
559
- case 'boolean':
560
- if (rawValue !== 'true' && rawValue !== 'false') return null;
561
- parsedValue = rawValue === 'true';
562
- break;
563
- default:
564
- return null;
565
- }
566
-
567
- return { fullPath: mapped.targetField, value: parsedValue };
568
- }
569
-
570
- /**
571
- * Apply a nested value to an object using dot notation path.
572
- */
573
- export function applyNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
574
- const parts = path.split('.');
575
- let current: unknown = obj;
576
-
577
- for (let i = 0; i < parts.length - 1; i++) {
578
- const key = parts[i]!;
579
- const currentRecord = current as Record<string, unknown>;
580
- if (
581
- !(key in currentRecord) ||
582
- typeof currentRecord[key] !== 'object' ||
583
- currentRecord[key] === null
584
- ) {
585
- currentRecord[key] = {};
586
- }
587
- current = currentRecord[key];
588
- }
589
-
590
- const finalKey = parts[parts.length - 1]!;
591
- (current as Record<string, unknown>)[finalKey] = value;
592
- }
593
-
594
- /**
595
- * Handle /openrouter model-override-set command.
596
- * Format: model-override-set <model-id> <field=value>...
597
- * Examples:
598
- * /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max
599
- * /openrouter model-override-set deepseek/deepseek-v4-pro contextWindow=128000
600
- */
601
- export async function handleModelOverrideSet(
602
- args: string,
603
- userOverrides: ModelOverridesFile,
604
- ): Promise<HandlerResult> {
605
- const parts = args.trim().split(/\s+/).filter(Boolean);
606
-
607
- if (parts.length < 1) {
608
- return {
609
- success: false,
610
- message:
611
- 'Usage: /openrouter model-override-set <model-id> <field=value>...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
612
- };
613
- }
614
-
615
- const modelId = parts[0];
616
-
617
- if (!modelId) {
618
- return {
619
- success: false,
620
- message:
621
- 'Usage: /openrouter model-override-set <model-id> <field=value>...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
622
- };
623
- }
624
-
625
- // Validate model ID format (should be provider/model)
626
- if (!modelId.includes('/')) {
627
- return {
628
- success: false,
629
- message: `Invalid model ID format: "${modelId}"\nExpected format: provider/model (e.g., "deepseek/deepseek-v4-pro")`,
630
- };
631
- }
632
-
633
- // Build override incrementally from assignments
634
- const override: UserModelOverride = {};
635
- const assignments = parts.slice(1).filter((p) => !p.startsWith('--'));
636
-
637
- for (const assignment of assignments) {
638
- const parsed = parseScopedAssignment(assignment);
639
- if (!parsed) {
640
- return {
641
- success: false,
642
- message: `Invalid assignment: "${assignment}"\nExpected format: field=value (e.g., thinking.high=high or contextWindow=128000)\nSee available fields with /openrouter model-override-list --fields`,
643
- };
644
- }
645
- applyNestedValue(override as Record<string, unknown>, parsed.fullPath, parsed.value);
646
- }
647
-
648
- // If no assignments provided, error out
649
- if (Object.keys(override).length === 0) {
650
- return {
651
- success: false,
652
- message:
653
- 'No field assignments provided.\nUsage: /openrouter model-override-set <model-id> field=value [field=value]...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
654
- };
655
- }
656
-
657
- // Update overrides file
658
- const updatedOverrides = setModelOverride(userOverrides, modelId, override);
659
- try {
660
- await saveModelOverrides(updatedOverrides);
661
- } catch (error) {
662
- return {
663
- success: false,
664
- message: `Failed to save overrides for ${modelId}: ${getErrorMessage(error)}`,
665
- };
666
- }
667
-
668
- const savedOverride = updatedOverrides.overrides[modelId] as UserModelOverride;
669
-
670
- // Format success message
671
- const lines: string[] = [`Saved overrides for ${modelId}:`];
672
- for (const [key, val] of Object.entries(savedOverride)) {
673
- if (key === 'thinkingLevelMap' && val) {
674
- lines.push(' thinkingLevelMap:');
675
- for (const [level, mapped] of Object.entries(val as ThinkingLevelMap)) {
676
- lines.push(` ${level}: ${mapped === null ? 'null' : mapped}`);
677
- }
678
- } else {
679
- lines.push(` ${key}: ${val}`);
680
- }
681
- }
682
-
683
- return {
684
- success: true,
685
- message: lines.join('\n'),
686
- modelId,
687
- };
688
- }
689
-
690
- /**
691
- * Handle /openrouter model-override-clear command.
692
- */
693
- export async function handleModelOverrideClear(
694
- args: string,
695
- userOverrides: ModelOverridesFile,
696
- ): Promise<HandlerResult> {
697
- const parts = args.trim().split(/\s+/).filter(Boolean);
698
- const modelId = parts[0];
699
-
700
- if (!modelId) {
701
- return {
702
- success: false,
703
- message: 'Usage: /openrouter model-override-clear <model-id>',
704
- };
705
- }
706
-
707
- if (!modelId.includes('/')) {
708
- return {
709
- success: false,
710
- message: `Invalid model ID format: "${modelId}"\nExpected format: provider/model`,
711
- };
712
- }
713
-
714
- const existing = getModelOverride(userOverrides, modelId);
715
- if (!existing) {
716
- return {
717
- success: false,
718
- message: `No overrides found for ${modelId}`,
719
- };
720
- }
721
-
722
- const updatedOverrides = removeModelOverride(userOverrides, modelId);
723
- try {
724
- await saveModelOverrides(updatedOverrides);
725
- } catch (error) {
726
- return {
727
- success: false,
728
- message: `Failed to clear overrides for ${modelId}: ${getErrorMessage(error)}`,
729
- };
730
- }
731
-
732
- return {
733
- success: true,
734
- message: `Cleared all overrides for ${modelId}`,
735
- modelId,
736
- };
737
- }
738
-
739
- /**
740
- * Handle /openrouter model-override-list command.
741
- */
742
- export async function handleModelOverrideList(args: string): Promise<string> {
743
- const userOverrides = await loadModelOverrides();
744
- const modelId = args.trim();
745
-
746
- // List available fields if --fields flag
747
- if (modelId === '--fields') {
748
- const fields = Object.keys(SCOPED_FIELD_MAP)
749
- .map(
750
- (k) => ` ${k}: ${SCOPED_FIELD_MAP[k]!.targetField} (${SCOPED_FIELD_MAP[k]!.targetType})`,
751
- )
752
- .join('\n');
753
- return `Available override fields:\n${fields}`;
754
- }
755
-
756
- if (!hasOverrides(userOverrides)) {
757
- return 'No model overrides configured.\nUse /openrouter model-override-set to add overrides.';
758
- }
759
-
760
- if (modelId) {
761
- // Show specific model
762
- const override = getModelOverride(userOverrides, modelId);
763
- if (!override) {
764
- return `No overrides configured for ${modelId}`;
765
- }
766
-
767
- const lines: string[] = [`Overrides for ${modelId}:`];
768
- for (const [key, val] of Object.entries(override)) {
769
- if (key === 'thinkingLevelMap' && val) {
770
- lines.push(' thinkingLevelMap:');
771
- for (const [level, mapped] of Object.entries(val as ThinkingLevelMap)) {
772
- lines.push(` ${level}: ${mapped === null ? 'null' : mapped}`);
773
- }
774
- } else {
775
- lines.push(` ${key}: ${val}`);
776
- }
777
- }
778
- return lines.join('\n');
779
- }
780
-
781
- // List all overrides
782
- const modelIds = getOverrideModelIds(userOverrides);
783
- const lines: string[] = [`${modelIds.length} model(s) with overrides:`];
784
- for (const id of modelIds) {
785
- const override = getModelOverride(userOverrides, id);
786
- if (override?.thinkingLevelMap && Object.keys(override.thinkingLevelMap).length > 0) {
787
- const tlm = Object.entries(override.thinkingLevelMap)
788
- .filter(([, v]) => v !== null)
789
- .map(([k, v]) => `${k}=${v}`)
790
- .join(',');
791
- lines.push(` ${id}${tlm ? ` [${tlm}]` : ''}`);
792
- } else {
793
- lines.push(` ${id}`);
794
- }
795
- }
796
- lines.push('\nUse /openrouter model-override-list <model-id> for details');
797
- return lines.join('\n');
798
- }
799
-
800
- async function showAccountOverlay(ctx: ExtensionContext) {
801
- let error: string | null = null;
802
- let keyInfo: KeyInfo[] | null = null;
803
- let credits: number | null = null;
804
-
805
- try {
806
- // Try to get all keys with management key
807
- const allKeys = await getAllKeys();
808
-
809
- if (allKeys && allKeys.length > 0) {
810
- keyInfo = allKeys;
811
- } else {
812
- // getAllKeys() returns null or empty array when management key isn't available
813
- // or when the API call fails with 403
814
- error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
815
-
816
- // Fall back to current key only
817
- try {
818
- const currentKey = await getCurrentKey();
819
- if (currentKey) {
820
- keyInfo = [currentKey];
821
- // Clear the error since we successfully got current key
822
- error = null;
823
- } else {
824
- error = 'Failed to retrieve current key metadata. Check your API key permissions.';
825
- }
826
- } catch (err) {
827
- error = `Failed to retrieve current key: ${(err as Error).message}`;
828
- }
829
- }
830
-
831
- // Try to get account credits
832
- credits = await getAccountCredits();
833
-
834
- // Set error if we have no keys and no credits
835
- if (!keyInfo && !credits) {
836
- error =
837
- 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.';
838
- }
839
-
840
- // Set error if we have credits but no keys
841
- if (!keyInfo && credits !== null) {
842
- error =
843
- error ||
844
- 'Key information unavailable. Set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
845
- }
846
-
847
- // Compute rollup status
848
- const rollupStatus = keyInfo
849
- ? computeRollupStatus(keyInfo)
850
- : { status: 'unavailable' as const };
851
-
852
- // Sort keys
853
- if (keyInfo) {
854
- const sortedKeys = sortKeys(keyInfo);
855
- keyInfo = sortedKeys;
856
- }
857
-
858
- await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
859
- } catch (error_) {
860
- const err = error_ as Error;
861
- error =
862
- err instanceof AuthError
863
- ? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.'
864
- : `API Error: ${err.message}`;
865
-
866
- // Try to get current key for overlay even on error
867
- try {
868
- const currentKey = await getCurrentKey();
869
- if (currentKey) {
870
- keyInfo = [currentKey];
871
- }
872
- } catch {
873
- // Ignore secondary errors
874
- }
875
-
876
- const rollupStatus = keyInfo
877
- ? computeRollupStatus(keyInfo)
878
- : { status: 'unavailable' as const };
879
-
880
- await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
881
- }
882
- }
883
-
884
- async function showAccountOverlayComponent(
885
- ctx: ExtensionContext,
886
- keyInfo: KeyInfo[] | null,
887
- credits: number | null,
888
- rollupStatus: RollupStatus,
889
- error: string | null,
890
- ) {
891
- await ctx.ui.custom<void>(
892
- (_tui, theme, _keybindings, done) => {
893
- const overlayComponent = new AccountOverlayComponent(
894
- keyInfo,
895
- credits,
896
- rollupStatus,
897
- error,
898
- theme,
899
- done,
900
- () => _tui.requestRender(),
901
- ctx,
902
- );
903
-
904
- return {
905
- handleInput: (data: string) => {
906
- overlayComponent.handleInput(data);
907
- _tui.requestRender();
908
- },
909
- render: (width: number) => overlayComponent.render(width),
910
- invalidate: () => overlayComponent.invalidate(),
911
- dispose: () => {
912
- overlayComponent.dispose();
913
- },
914
- wantsKeyRelease: false,
915
- };
916
- },
917
- {
918
- overlay: true,
919
- overlayOptions: {
920
- width: 100,
921
- },
922
- },
923
- );
924
- }
925
-
926
- async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
927
- const cachedSummary = usageCache.get('usage');
928
- const lastFetchTimestamp = usageCache.getTimestamp('usage');
929
- const cachedMinutesAgo = lastFetchTimestamp
930
- ? Math.round((Date.now() - lastFetchTimestamp) / MS_PER_MINUTE)
931
- : null;
932
-
933
- if (cachedSummary) {
934
- await showOverlay(ctx, cachedSummary, null, cachedMinutesAgo);
935
- return;
936
- }
937
-
938
- let error: string | null = null;
939
- let summary: UsageSummary | null = null;
940
-
941
- try {
942
- summary = await fetchAndAggregate();
943
- if (!summary) {
944
- error =
945
- 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.';
946
- } else {
947
- usageCache.set('usage', summary);
948
- }
949
-
950
- await showOverlay(ctx, summary, error, 0);
951
- } catch (error_) {
952
- const err = error_ as Error;
953
- error =
954
- err instanceof AuthError
955
- ? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.'
956
- : `API Error: ${err.message}`;
957
- await showOverlay(ctx, null, error, cachedMinutesAgo || 0);
958
- }
959
- }
14
+ initializeSessionState();
960
15
 
961
- async function showOverlay(
962
- ctx: ExtensionContext,
963
- summary: UsageSummary | null,
964
- error: string | null,
965
- cachedMinutesAgo: number | null,
966
- ) {
967
- await ctx.ui.custom<void>(
968
- (_tui, theme, _keybindings, done) => {
969
- const overlayComponent = new UsageOverlayComponent(
970
- summary,
971
- error,
972
- cachedMinutesAgo,
973
- theme,
974
- done,
975
- () => _tui.requestRender(),
976
- );
16
+ const startupState = await loadStartupCacheState(pi);
977
17
 
978
- return {
979
- handleInput: (data: string) => {
980
- overlayComponent.handleInput(data);
981
- _tui.requestRender();
982
- },
983
- render: (width: number) => overlayComponent.render(width),
984
- invalidate: () => overlayComponent.invalidate(),
985
- dispose: () => {
986
- overlayComponent.dispose();
987
- },
988
- };
989
- },
990
- {
991
- overlay: true,
992
- overlayOptions: {
993
- width: 100,
994
- },
995
- },
996
- );
18
+ installOpenRouterHooks(pi, startupState);
19
+ registerOpenRouterCommands(pi);
997
20
  }