@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.
Files changed (40) hide show
  1. package/README.md +87 -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 +163 -0
  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 -589
  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-overrides.test.ts +102 -0
  24. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  25. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  26. package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
  27. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  28. package/extensions/openrouter/models/cache.ts +27 -2
  29. package/extensions/openrouter/models/mapper.ts +60 -77
  30. package/extensions/openrouter/models/override-commands.ts +434 -0
  31. package/extensions/openrouter/models/overrides.ts +174 -0
  32. package/extensions/openrouter/models/skip-hints.ts +19 -0
  33. package/extensions/openrouter/models/sync.ts +22 -10
  34. package/extensions/openrouter/models/types.ts +31 -1
  35. package/extensions/openrouter/normalizers.ts +128 -0
  36. package/extensions/openrouter/overlay.ts +19 -8
  37. package/extensions/openrouter/session-state.ts +110 -0
  38. package/extensions/openrouter/session.ts +16 -0
  39. package/extensions/openrouter/types.ts +28 -9
  40. package/package.json +1 -1
@@ -0,0 +1,555 @@
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';
4
+ import {
5
+ usageCache,
6
+ startBackgroundRefresh,
7
+ fetchAndAggregate,
8
+ isRateLimitError,
9
+ } from './cache.js';
10
+ import { AuthError } from './client.js';
11
+ import { UsageOverlayComponent } from './overlay.js';
12
+ import { getCurrentSessionId } from './hooks.js';
13
+ import { AccountOverlayComponent } from './account-overlay.js';
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';
17
+ import {
18
+ syncModels,
19
+ getSyncState,
20
+ isSyncEnabled,
21
+ getSkipReasonsAsync,
22
+ groupSkipReasons,
23
+ } from './models/sync.js';
24
+ import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
25
+ import { loadModelOverrides } from './models/overrides.js';
26
+ import { getSkipReasonHint } from './models/skip-hints.js';
27
+ import type { ModelOverridesFile } from './models/types.js';
28
+ import {
29
+ handleModelOverrideSet,
30
+ handleModelOverrideClear,
31
+ handleModelOverrideList,
32
+ } from './models/override-commands.js';
33
+
34
+ export const OPENROUTER_SUBCOMMANDS = [
35
+ 'usage',
36
+ 'account',
37
+ 'session',
38
+ 'models-sync',
39
+ 'models-status',
40
+ 'model-override-set',
41
+ 'model-override-clear',
42
+ 'model-override-list',
43
+ ] as const;
44
+
45
+ export function registerOpenRouterCommands(pi: Pick<ExtensionAPI, 'registerCommand'>): void {
46
+ pi.registerCommand('openrouter-usage', {
47
+ description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
48
+ getArgumentCompletions: () => null,
49
+ handler: async (args, ctx) => {
50
+ startUsageBackgroundRefresh(ctx);
51
+ const subcommand = args.trim() || undefined;
52
+ await showUsageOverlay(ctx, subcommand);
53
+ },
54
+ });
55
+
56
+ pi.registerCommand('openrouter-session', {
57
+ description: 'Show the current OpenRouter session ID for request grouping',
58
+ getArgumentCompletions: () => null,
59
+ handler: async (_args, ctx) => {
60
+ notifyCurrentSession(ctx);
61
+ },
62
+ });
63
+
64
+ pi.registerCommand('openrouter-account', {
65
+ description: 'Show OpenRouter account and key health',
66
+ getArgumentCompletions: () => null,
67
+ handler: async (_args, ctx) => {
68
+ await showAccountOverlay(ctx);
69
+ },
70
+ });
71
+
72
+ pi.registerCommand('openrouter', {
73
+ description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
74
+ getArgumentCompletions: getOpenRouterSubcommandCompletions,
75
+ handler: async (args, ctx) => {
76
+ await handleOpenRouterCommand(args, ctx);
77
+ },
78
+ });
79
+ }
80
+
81
+ function getOpenRouterSubcommandCompletions(prefix: string) {
82
+ const items = OPENROUTER_SUBCOMMANDS.filter((subcommand) => subcommand.startsWith(prefix)).map(
83
+ (subcommand) => ({ value: subcommand, label: subcommand }),
84
+ );
85
+ return items.length > 0 ? items : null;
86
+ }
87
+
88
+ async function handleOpenRouterCommand(args: string, ctx: ExtensionContext): Promise<void> {
89
+ const { subcommand, subcommandArgs, flags } = parseOpenRouterCommandArgs(args);
90
+
91
+ switch (subcommand) {
92
+ case 'usage': {
93
+ startUsageBackgroundRefresh(ctx);
94
+ await showUsageOverlay(ctx, undefined);
95
+ break;
96
+ }
97
+ case 'account': {
98
+ await showAccountOverlay(ctx);
99
+ break;
100
+ }
101
+ case 'session': {
102
+ notifyCurrentSession(ctx);
103
+ break;
104
+ }
105
+ case 'models-sync': {
106
+ await handleModelsSyncCommand(ctx);
107
+ break;
108
+ }
109
+ case 'models-status': {
110
+ await handleModelsStatusCommand(ctx, flags);
111
+ break;
112
+ }
113
+ case 'model-override-set': {
114
+ await handleModelOverrideSetCommand(subcommandArgs, ctx);
115
+ break;
116
+ }
117
+ case 'model-override-clear': {
118
+ await handleModelOverrideClearCommand(subcommandArgs, ctx);
119
+ break;
120
+ }
121
+ case 'model-override-list': {
122
+ await handleModelOverrideListCommand(subcommandArgs, ctx);
123
+ break;
124
+ }
125
+ default: {
126
+ notifyUnknownSubcommand(ctx);
127
+ break;
128
+ }
129
+ }
130
+ }
131
+
132
+ function parseOpenRouterCommandArgs(args: string): {
133
+ subcommand: string;
134
+ subcommandArgs: string;
135
+ flags: Record<string, boolean>;
136
+ } {
137
+ const parts = args.trim().split(/\s+/);
138
+ const subcommand = parts[0] || '';
139
+ const subcommandArgs = parts.slice(1).join(' ').trim();
140
+ const flags = parts.slice(1).reduce(
141
+ (acc, flag) => {
142
+ acc[flag] = true;
143
+ return acc;
144
+ },
145
+ {} as Record<string, boolean>,
146
+ );
147
+
148
+ return { subcommand, subcommandArgs, flags };
149
+ }
150
+
151
+ function notifyCurrentSession(ctx: {
152
+ sessionManager: { getSessionId(): string };
153
+ ui: { notify(message: string, level: 'info'): void };
154
+ }): void {
155
+ ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
156
+ }
157
+
158
+ async function handleModelsSyncCommand(ctx: ExtensionContext): Promise<void> {
159
+ if (!isSyncEnabled()) {
160
+ ctx.ui.notify(
161
+ 'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
162
+ 'error',
163
+ );
164
+ return;
165
+ }
166
+
167
+ const result = await syncModels(ctx);
168
+ if (!result.success) {
169
+ let message = '';
170
+ if (result.source === 'cache') {
171
+ message = `OpenRouter models sync failed\n${result.registeredCount} registered from cache\nCache age: ${formatDuration(result.cacheAgeMs)}\nError: ${result.error}`;
172
+ } else {
173
+ message = `OpenRouter models unavailable\n0 registered\nError: ${result.error}`;
174
+ }
175
+ ctx.ui.notify(message, result.source === 'cache' ? 'warning' : 'error');
176
+ return;
177
+ }
178
+
179
+ const message = `OpenRouter models synced\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache updated`;
180
+ ctx.ui.notify(message, 'info');
181
+ }
182
+
183
+ async function handleModelsStatusCommand(
184
+ ctx: ExtensionContext,
185
+ flags: Record<string, boolean>,
186
+ ): Promise<void> {
187
+ const state = getSyncState();
188
+ const skipReasons = await getSkipReasonsAsync();
189
+ const groupedReasons = groupSkipReasons(skipReasons);
190
+
191
+ const cache = await loadCache();
192
+ const cacheAgeMs = cache ? getCacheAgeMs(cache) : null;
193
+
194
+ if (!state && !cache) {
195
+ ctx.ui.notify('OpenRouter models: not synced', 'error');
196
+ return;
197
+ }
198
+
199
+ if (!state && cache) {
200
+ const cachedCount = cache.models.length;
201
+ const message = `OpenRouter models cached\n${cachedCount} models in cache · age: ${formatDuration(cacheAgeMs)}\nRun '/openrouter models-sync' to register models`;
202
+ ctx.ui.notify(message, 'info');
203
+ return;
204
+ }
205
+
206
+ if (state?.success) {
207
+ const skipCount = skipReasons.length;
208
+ let message = `OpenRouter models healthy\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''} · cache age: ${formatDuration(cacheAgeMs)}`;
209
+
210
+ if (flags['--skipped']) {
211
+ message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
212
+ }
213
+ ctx.ui.notify(message, 'info');
214
+ return;
215
+ }
216
+
217
+ if (state?.source === 'cache') {
218
+ const skipCount = skipReasons.length;
219
+ let message = `OpenRouter models cached\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''}\nCache age: ${formatDuration(cacheAgeMs)}\nError: ${state.error}`;
220
+
221
+ if (flags['--skipped']) {
222
+ message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
223
+ }
224
+ ctx.ui.notify(message, 'warning');
225
+ return;
226
+ }
227
+
228
+ ctx.ui.notify(`OpenRouter models broken\n0 registered\nError: ${state?.error}`, 'error');
229
+ }
230
+
231
+ async function handleModelOverrideSetCommand(
232
+ subcommandArgs: string,
233
+ ctx: ExtensionContext,
234
+ ): Promise<void> {
235
+ let userOverrides: ModelOverridesFile;
236
+ try {
237
+ userOverrides = await loadModelOverrides();
238
+ } catch (error) {
239
+ ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
240
+ return;
241
+ }
242
+
243
+ const result = await handleModelOverrideSet(subcommandArgs, userOverrides);
244
+ if (result.success) {
245
+ ctx.ui.notify(result.message, 'info');
246
+ if (result.modelId && ctx.model && result.modelId === ctx.model.id) {
247
+ ctx.ui.notify(
248
+ 'Model configuration updated. Run /openrouter models-sync to apply changes to the current conversation.',
249
+ 'info',
250
+ );
251
+ }
252
+ return;
253
+ }
254
+
255
+ ctx.ui.notify(result.message, 'error');
256
+ }
257
+
258
+ async function handleModelOverrideClearCommand(
259
+ subcommandArgs: string,
260
+ ctx: ExtensionContext,
261
+ ): Promise<void> {
262
+ let userOverrides: ModelOverridesFile;
263
+ try {
264
+ userOverrides = await loadModelOverrides();
265
+ } catch (error) {
266
+ ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
267
+ return;
268
+ }
269
+
270
+ const result = await handleModelOverrideClear(subcommandArgs, userOverrides);
271
+ if (result.success) {
272
+ ctx.ui.notify(result.message, 'info');
273
+ if (result.modelId && ctx.model && result.modelId === ctx.model.id) {
274
+ ctx.ui.notify(
275
+ 'Model configuration updated. Run /openrouter models-sync to apply changes to the current conversation.',
276
+ 'info',
277
+ );
278
+ }
279
+ return;
280
+ }
281
+
282
+ ctx.ui.notify(result.message, 'error');
283
+ }
284
+
285
+ async function handleModelOverrideListCommand(
286
+ subcommandArgs: string,
287
+ ctx: ExtensionContext,
288
+ ): Promise<void> {
289
+ try {
290
+ const result = await handleModelOverrideList(subcommandArgs);
291
+ ctx.ui.notify(result, 'info');
292
+ } catch (error) {
293
+ ctx.ui.notify(`Failed to load model overrides: ${getErrorMessage(error)}`, 'error');
294
+ }
295
+ }
296
+
297
+ function notifyUnknownSubcommand(ctx: ExtensionContext): void {
298
+ const message = `Available subcommands: ${OPENROUTER_SUBCOMMANDS.join(', ')}`;
299
+ ctx.ui.notify(`OpenRouter subcommands\n${message}`, 'error');
300
+ }
301
+
302
+ function getErrorMessage(error: unknown): string {
303
+ return error instanceof Error ? error.message : String(error);
304
+ }
305
+
306
+ function formatSkippedDetails(
307
+ skipCount: number,
308
+ groupedReasons: Record<string, number>,
309
+ skipReasons: Array<{ id: string; reason: string; hint?: string }>,
310
+ ): string {
311
+ if (skipCount === 0) {
312
+ return '\n\nNo skipped models';
313
+ }
314
+
315
+ let details = `\n\nOpenRouter skipped models: ${skipCount}\n`;
316
+ for (const [reason, count] of Object.entries(groupedReasons)) {
317
+ details += `\n${count} ${reason}\n`;
318
+
319
+ const hint =
320
+ skipReasons.find((item) => item.reason === reason)?.hint ?? getSkipReasonHint(reason);
321
+ if (hint) {
322
+ details += ` suggestion: ${hint}\n`;
323
+ }
324
+
325
+ const modelsWithReason = skipReasons
326
+ .filter((item) => item.reason === reason)
327
+ .map((item) => item.id);
328
+ for (const id of modelsWithReason) {
329
+ details += `- ${id}\n`;
330
+ }
331
+ }
332
+ return details;
333
+ }
334
+
335
+ async function showAccountOverlay(ctx: ExtensionContext) {
336
+ let error: string | null = null;
337
+ let keyInfo: KeyInfo[] | null = null;
338
+ let credits: number | null = null;
339
+
340
+ try {
341
+ const allKeys = await getAllKeys();
342
+
343
+ if (allKeys && allKeys.length > 0) {
344
+ keyInfo = allKeys;
345
+ } else {
346
+ error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
347
+
348
+ try {
349
+ const currentKey = await getCurrentKey();
350
+ if (currentKey) {
351
+ keyInfo = [currentKey];
352
+ error = null;
353
+ } else {
354
+ error = 'Failed to retrieve current key metadata. Check your API key permissions.';
355
+ }
356
+ } catch (err) {
357
+ error = `Failed to retrieve current key: ${(err as Error).message}`;
358
+ }
359
+ }
360
+
361
+ credits = await getAccountCredits();
362
+
363
+ if (!keyInfo && !credits) {
364
+ error =
365
+ 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.';
366
+ }
367
+
368
+ if (!keyInfo && credits !== null) {
369
+ error =
370
+ error ||
371
+ 'Key information unavailable. Set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
372
+ }
373
+
374
+ const rollupStatus = keyInfo
375
+ ? computeRollupStatus(keyInfo)
376
+ : { status: 'unavailable' as const };
377
+
378
+ if (keyInfo) {
379
+ keyInfo = sortKeys(keyInfo);
380
+ }
381
+
382
+ await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
383
+ } catch (error_) {
384
+ const err = error_ as Error;
385
+ error =
386
+ err instanceof AuthError
387
+ ? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.'
388
+ : `API Error: ${err.message}`;
389
+
390
+ try {
391
+ const currentKey = await getCurrentKey();
392
+ if (currentKey) {
393
+ keyInfo = [currentKey];
394
+ }
395
+ } catch {
396
+ // Ignore secondary errors
397
+ }
398
+
399
+ const rollupStatus = keyInfo
400
+ ? computeRollupStatus(keyInfo)
401
+ : { status: 'unavailable' as const };
402
+
403
+ await showAccountOverlayComponent(ctx, keyInfo, credits, rollupStatus, error);
404
+ }
405
+ }
406
+
407
+ async function showAccountOverlayComponent(
408
+ ctx: ExtensionContext,
409
+ keyInfo: KeyInfo[] | null,
410
+ credits: number | null,
411
+ rollupStatus: RollupStatus,
412
+ error: string | null,
413
+ ) {
414
+ await ctx.ui.custom<void>(
415
+ (_tui, theme, _keybindings, done) => {
416
+ const overlayComponent = new AccountOverlayComponent(
417
+ keyInfo,
418
+ credits,
419
+ rollupStatus,
420
+ error,
421
+ theme,
422
+ done,
423
+ () => _tui.requestRender(),
424
+ ctx,
425
+ );
426
+
427
+ return {
428
+ handleInput: (data: string) => {
429
+ overlayComponent.handleInput(data);
430
+ _tui.requestRender();
431
+ },
432
+ render: (width: number) => overlayComponent.render(width),
433
+ invalidate: () => overlayComponent.invalidate(),
434
+ dispose: () => {
435
+ overlayComponent.dispose();
436
+ },
437
+ wantsKeyRelease: false,
438
+ };
439
+ },
440
+ {
441
+ overlay: true,
442
+ overlayOptions: {
443
+ width: 100,
444
+ },
445
+ },
446
+ );
447
+ }
448
+
449
+ function startUsageBackgroundRefresh(ctx: ExtensionContext): void {
450
+ startBackgroundRefresh({
451
+ onFailure: (state) => {
452
+ if (!ctx.hasUI || !state.lastError) return;
453
+
454
+ const isPersistent = state.consecutiveFailures >= 4;
455
+ const isRateLimited = isRateLimitError(state.lastError);
456
+ if (!isPersistent && !isRateLimited) return;
457
+
458
+ const staleSuffix = state.status === 'stale' ? '\nShowing last successful usage data.' : '';
459
+ ctx.ui.notify(
460
+ `OpenRouter usage refresh ${state.status}\n${state.lastError}${staleSuffix}`,
461
+ 'warning',
462
+ );
463
+ },
464
+ });
465
+ }
466
+
467
+ async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
468
+ const cachedSummary = usageCache.get('usage');
469
+ const lastFetchTimestamp = usageCache.getTimestamp('usage');
470
+ const cachedMinutesAgo = lastFetchTimestamp
471
+ ? Math.round((Date.now() - lastFetchTimestamp) / MS_PER_MINUTE)
472
+ : null;
473
+
474
+ if (cachedSummary) {
475
+ await showOverlay(ctx, cachedSummary, null, cachedMinutesAgo);
476
+ return;
477
+ }
478
+
479
+ const staleSummary = usageCache.get('usage', { allowStale: true });
480
+ const staleFetchTimestamp = usageCache.getTimestamp('usage', { allowStale: true });
481
+ const staleMinutesAgo = staleFetchTimestamp
482
+ ? Math.round((Date.now() - staleFetchTimestamp) / MS_PER_MINUTE)
483
+ : null;
484
+
485
+ let error: string | null = null;
486
+ let summary: UsageSummary | null = null;
487
+
488
+ try {
489
+ summary = await fetchAndAggregate();
490
+ if (!summary) {
491
+ error =
492
+ 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.';
493
+ await showOverlay(
494
+ ctx,
495
+ staleSummary ?? null,
496
+ staleSummary ? `${error}\nShowing last successful usage data.` : error,
497
+ staleSummary ? staleMinutesAgo : null,
498
+ );
499
+ return;
500
+ }
501
+
502
+ usageCache.set('usage', summary);
503
+ await showOverlay(ctx, summary, error, 0);
504
+ } catch (error_) {
505
+ const err = error_ as Error;
506
+ error =
507
+ err instanceof AuthError
508
+ ? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.'
509
+ : `API Error: ${err.message}`;
510
+ await showOverlay(
511
+ ctx,
512
+ staleSummary ?? null,
513
+ staleSummary ? `${error}\nShowing last successful usage data.` : error,
514
+ staleSummary ? staleMinutesAgo : null,
515
+ );
516
+ }
517
+ }
518
+
519
+ async function showOverlay(
520
+ ctx: ExtensionContext,
521
+ summary: UsageSummary | null,
522
+ error: string | null,
523
+ cachedMinutesAgo: number | null,
524
+ ) {
525
+ await ctx.ui.custom<void>(
526
+ (_tui, theme, _keybindings, done) => {
527
+ const overlayComponent = new UsageOverlayComponent(
528
+ summary,
529
+ error,
530
+ cachedMinutesAgo,
531
+ theme,
532
+ done,
533
+ () => _tui.requestRender(),
534
+ );
535
+
536
+ return {
537
+ handleInput: (data: string) => {
538
+ overlayComponent.handleInput(data);
539
+ _tui.requestRender();
540
+ },
541
+ render: (width: number) => overlayComponent.render(width),
542
+ invalidate: () => overlayComponent.invalidate(),
543
+ dispose: () => {
544
+ overlayComponent.dispose();
545
+ },
546
+ };
547
+ },
548
+ {
549
+ overlay: true,
550
+ overlayOptions: {
551
+ width: 100,
552
+ },
553
+ },
554
+ );
555
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
2
2
  import type { ModelStats, ProviderStats, TokenStats, UsageSummary } from './types.js';
3
- import { ZERO_AGGREGATE, type LocalUsageEvent } from './types.js';
3
+ import { combineUsageAggregates, createZeroAggregate, type LocalUsageEvent } from './types.js';
4
4
  import { getUtcDateFromTimestamp } from './local-usage.js';
5
5
 
6
6
  /** Convert a Date to YYYY-MM-DD string in UTC (matching OpenRouter API format) */
@@ -70,6 +70,9 @@ export function aggregateUsage(
70
70
  .sort((a, b) => b.spend30d - a.spend30d)
71
71
  .slice(0, 10);
72
72
 
73
+ const official = createZeroAggregate();
74
+ const local = createZeroAggregate();
75
+
73
76
  const summary = {
74
77
  today,
75
78
  week,
@@ -82,9 +85,9 @@ export function aggregateUsage(
82
85
  timestamp,
83
86
  hasActivityData: true, // aggregateUsage is only called when analytics data is available
84
87
  officialThroughDate: undefined as string | undefined,
85
- official: ZERO_AGGREGATE,
86
- local: ZERO_AGGREGATE,
87
- combined: ZERO_AGGREGATE,
88
+ official,
89
+ local,
90
+ combined: combineUsageAggregates(official, local),
88
91
  } as UsageSummary;
89
92
 
90
93
  return summary;