@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.
@@ -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: string; // Key hash for identification
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 { getAllKeys, getCurrentKey, getAccountCredits } from './account-client.js';
16
- import type { KeyInfo, RollupStatus } from './account-types.js';
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: 'OpenRouter commands: usage, account, session, models-sync, models-status',
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 allKeys = await getAllKeys();
435
+ const keyInventory = await getAllKeys();
436
+ canManageKeys = keyInventory.canManageKeys;
342
437
 
343
- if (allKeys && allKeys.length > 0) {
344
- keyInfo = allKeys;
345
- } else {
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(ctx, keyInfo, credits, rollupStatus, error);
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', async (event, ctx) => {
133
- await captureLocalUsage(event as unknown, ctx);
136
+ pi.on('turn_end', (event, ctx) => {
137
+ captureLocalUsage(event as unknown, ctx);
134
138
  });
135
139
  }
136
140
 
137
- async function captureLocalUsage(
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).catch(() => {});
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
- if (startupState.info) {
217
- const statusText = `OpenRouter ${startupState.info.count} models`;
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
- * Aggregate local usage events into UsageAggregate.
169
- * Deduplicates by id (first occurrence wins).
168
+ * Deduplicate local usage events by id (first occurrence wins).
170
169
  */
171
- export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
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
- // Aggregate
187
- const result = unique.reduce((acc, event) => {
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;