@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
@@ -2,11 +2,45 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
4
  import type { LocalUsageEvent, UsageAggregate } from './types.js';
5
- import { ZERO_AGGREGATE } from './types.js';
5
+ import { createZeroAggregate } from './types.js';
6
6
 
7
7
  export type { LocalUsageEvent };
8
8
 
9
- const LOCAL_USAGE_DIR = path.join(os.homedir(), '.pi', 'openrouter', 'usage');
9
+ const DEFAULT_LOCAL_USAGE_DIR = path.join(os.homedir(), '.pi', 'openrouter', 'usage');
10
+
11
+ // Allow overriding usage directory for testing
12
+ let localUsageDirOverride: string | null = null;
13
+
14
+ /**
15
+ * Get the local usage directory.
16
+ * Uses override if set (for testing), otherwise uses default.
17
+ */
18
+ function getLocalUsageDir(): string {
19
+ return localUsageDirOverride ?? DEFAULT_LOCAL_USAGE_DIR;
20
+ }
21
+
22
+ /**
23
+ * Set a custom local usage directory (for testing).
24
+ * Pass null to reset to default.
25
+ */
26
+ export function setLocalUsageDir(dir: string | null): void {
27
+ localUsageDirOverride = dir;
28
+ }
29
+
30
+ /**
31
+ * Default retention period for local usage files (90 days).
32
+ * Files older than this will be deleted during opportunistic cleanup.
33
+ */
34
+ const DEFAULT_RETENTION_DAYS = 90;
35
+
36
+ /**
37
+ * Check if debug logging is enabled via environment variable.
38
+ * When PI_OPENROUTER_DEBUG_USAGE=1, verbose logging is enabled.
39
+ * Otherwise, logging is quiet to avoid noise.
40
+ */
41
+ function isDebugEnabled(): boolean {
42
+ return process.env['PI_OPENROUTER_DEBUG_USAGE'] === '1';
43
+ }
10
44
 
11
45
  /**
12
46
  * Get current UTC date as YYYY-MM-DD
@@ -49,19 +83,33 @@ export function* iterateDates(start: string, end: string): Generator<string> {
49
83
  /**
50
84
  * Append a single LocalUsageEvent to the appropriate daily JSONL file.
51
85
  * File is determined by UTC date from completedAt.
86
+ *
87
+ * Performs opportunistic cleanup of old files after successful write.
88
+ * All errors fail open - logged only when debug flag is enabled.
52
89
  */
53
90
  export async function writeLocalUsage(event: LocalUsageEvent): Promise<void> {
54
91
  try {
55
92
  const dateStr = getUtcDateFromTimestamp(event.completedAt);
56
- const filePath = path.join(LOCAL_USAGE_DIR, `${dateStr}.jsonl`);
93
+ const usageDir = getLocalUsageDir();
94
+ const filePath = path.join(usageDir, `${dateStr}.jsonl`);
57
95
 
58
- await fs.mkdir(LOCAL_USAGE_DIR, { recursive: true });
96
+ await fs.mkdir(usageDir, { recursive: true });
59
97
 
60
98
  const line = JSON.stringify(event) + '\n';
61
99
  await fs.appendFile(filePath, line, 'utf8');
100
+
101
+ // Opportunistically clean up old files after successful write
102
+ // This is fire-and-forget - errors are caught and logged only if debug is enabled
103
+ cleanupOldUsageFiles().catch((err) => {
104
+ if (isDebugEnabled()) {
105
+ console.error('[local-usage] Background cleanup failed:', err);
106
+ }
107
+ });
62
108
  } catch (err) {
63
109
  // Fail open - log but don't throw to avoid breaking the user experience
64
- console.error('[local-usage] Failed to write usage event:', err);
110
+ if (isDebugEnabled()) {
111
+ console.error('[local-usage] Failed to write usage event:', err);
112
+ }
65
113
  }
66
114
  }
67
115
 
@@ -78,9 +126,10 @@ export interface ReadLocalUsageOptions {
78
126
  */
79
127
  export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<LocalUsageEvent[]> {
80
128
  const events: LocalUsageEvent[] = [];
129
+ const usageDir = getLocalUsageDir();
81
130
 
82
131
  for (const dateStr of iterateDates(options.fromDateUtc, options.toDateUtc)) {
83
- const filePath = path.join(LOCAL_USAGE_DIR, `${dateStr}.jsonl`);
132
+ const filePath = path.join(usageDir, `${dateStr}.jsonl`);
84
133
 
85
134
  try {
86
135
  const content = await fs.readFile(filePath, 'utf8');
@@ -95,7 +144,9 @@ export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<Lo
95
144
  events.push(event);
96
145
  } catch (parseErr) {
97
146
  // Skip malformed lines but continue
98
- console.warn(`[local-usage] Malformed line in ${dateStr}.jsonl:`, parseErr);
147
+ if (isDebugEnabled()) {
148
+ console.warn(`[local-usage] Malformed line in ${dateStr}.jsonl:`, parseErr);
149
+ }
99
150
  }
100
151
  }
101
152
  } catch (err) {
@@ -103,7 +154,9 @@ export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<Lo
103
154
  // Missing file is OK - just no data for this date
104
155
  continue;
105
156
  }
106
- console.error(`[local-usage] Failed to read ${dateStr}.jsonl:`, err);
157
+ if (isDebugEnabled()) {
158
+ console.error(`[local-usage] Failed to read ${dateStr}.jsonl:`, err);
159
+ }
107
160
  // Continue to next date despite error
108
161
  }
109
162
  }
@@ -117,7 +170,7 @@ export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<Lo
117
170
  */
118
171
  export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
119
172
  if (events.length === 0) {
120
- return ZERO_AGGREGATE;
173
+ return createZeroAggregate();
121
174
  }
122
175
 
123
176
  // Deduplicate by id
@@ -131,18 +184,88 @@ export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
131
184
  }
132
185
 
133
186
  // Aggregate
134
- const result = unique.reduce(
135
- (acc, event) => {
136
- acc.requests += event.requests ?? 1;
137
- acc.promptTokens += event.promptTokens || 0;
138
- acc.completionTokens += event.completionTokens || 0;
139
- acc.reasoningTokens += event.reasoningTokens || 0;
140
- acc.cacheReadTokens += event.cacheReadTokens || 0;
141
- acc.cacheWriteTokens += event.cacheWriteTokens || 0;
142
- acc.cost += event.cost || 0;
143
- return acc;
144
- },
145
- { ...ZERO_AGGREGATE },
146
- );
187
+ const result = unique.reduce((acc, event) => {
188
+ acc.requests += event.requests ?? 1;
189
+ acc.promptTokens += event.promptTokens || 0;
190
+ acc.completionTokens += event.completionTokens || 0;
191
+ acc.reasoningTokens += event.reasoningTokens || 0;
192
+ acc.cacheReadTokens += event.cacheReadTokens || 0;
193
+ acc.cacheWriteTokens += event.cacheWriteTokens || 0;
194
+ acc.cost += event.cost || 0;
195
+ return acc;
196
+ }, createZeroAggregate());
147
197
  return result;
148
198
  }
199
+
200
+ export interface CleanupOptions {
201
+ /** Number of days to retain (default: 90) */
202
+ retentionDays?: number;
203
+ }
204
+
205
+ /**
206
+ * Delete local usage files older than the retention window.
207
+ *
208
+ * This is called opportunistically after writes and fails open.
209
+ * Errors are logged only when debug flag is enabled.
210
+ *
211
+ * @param options - Cleanup configuration
212
+ * @param options.retentionDays - Days to retain (default: 90)
213
+ */
214
+ export async function cleanupOldUsageFiles(options: CleanupOptions = {}): Promise<void> {
215
+ const retentionDays = options.retentionDays ?? DEFAULT_RETENTION_DAYS;
216
+ const usageDir = getLocalUsageDir();
217
+
218
+ try {
219
+ // Calculate cutoff date
220
+ const today = getCurrentUtcDate();
221
+ const cutoffDate = addUtcDays(today, -retentionDays);
222
+
223
+ // List all files in usage directory
224
+ const files = await fs.readdir(usageDir);
225
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
226
+
227
+ let deletedCount = 0;
228
+
229
+ for (const filename of jsonlFiles) {
230
+ try {
231
+ // Extract date from filename (YYYY-MM-DD.jsonl)
232
+ const dateStr = filename.replace('.jsonl', '');
233
+
234
+ // Validate date format and value
235
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
236
+ // Skip malformed filenames
237
+ continue;
238
+ }
239
+
240
+ const fileDate = new Date(dateStr + 'T00:00:00Z');
241
+ if (isNaN(fileDate.getTime())) {
242
+ // Skip invalid dates
243
+ continue;
244
+ }
245
+
246
+ // Delete if older than cutoff
247
+ if (dateStr < cutoffDate) {
248
+ const filePath = path.join(usageDir, filename);
249
+ await fs.unlink(filePath);
250
+ deletedCount++;
251
+ }
252
+ } catch (err) {
253
+ // Skip individual file errors
254
+ if (isDebugEnabled()) {
255
+ console.error(`[local-usage] Failed to delete ${filename}:`, err);
256
+ }
257
+ }
258
+ }
259
+
260
+ if (isDebugEnabled() && deletedCount > 0) {
261
+ console.log(
262
+ `[local-usage] Cleaned up ${deletedCount} old usage file${deletedCount === 1 ? '' : 's'}`,
263
+ );
264
+ }
265
+ } catch (err) {
266
+ // Fail open on directory read errors
267
+ if (isDebugEnabled()) {
268
+ console.error('[local-usage] Failed to cleanup old usage files:', err);
269
+ }
270
+ }
271
+ }
@@ -1,9 +1,16 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { mkdir, rm, writeFile } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
- import { loadCache, saveCache, getCacheAgeMs, formatCacheAge, setCacheDir } from '../cache.js';
6
+ import {
7
+ loadCache,
8
+ saveCache,
9
+ getCacheAgeMs,
10
+ formatDuration,
11
+ formatCacheAge,
12
+ setCacheDir,
13
+ } from '../cache.js';
7
14
  import { createMockCache } from '../../__tests__/fixtures.js';
8
15
 
9
16
  // Each test gets its own isolated temp directory
@@ -65,6 +72,18 @@ describe('loadCache', () => {
65
72
  const result = await loadCache();
66
73
  expect(result).toBeNull();
67
74
  });
75
+
76
+ it('should return null when cache timestamp is too far in the future', async () => {
77
+ await mkdir(testCacheDir, { recursive: true });
78
+ const cacheFile = join(testCacheDir, 'models-cache.json');
79
+ await writeFile(
80
+ cacheFile,
81
+ JSON.stringify(createMockCache({ timestamp: Date.now() + 6 * 60000 })),
82
+ );
83
+
84
+ const result = await loadCache();
85
+ expect(result).toBeNull();
86
+ });
68
87
  });
69
88
 
70
89
  describe('saveCache', () => {
@@ -93,6 +112,24 @@ describe('saveCache', () => {
93
112
  const loaded = await loadCache();
94
113
  expect(loaded!.timestamp).toBe(2000);
95
114
  });
115
+
116
+ it('should clamp future timestamps when saving', async () => {
117
+ vi.useFakeTimers();
118
+ vi.setSystemTime(new Date('2026-05-22T12:00:00.000Z'));
119
+
120
+ try {
121
+ const now = Date.now();
122
+ const mockCache = createMockCache({ timestamp: now + 60000 });
123
+
124
+ await saveCache(mockCache);
125
+
126
+ const loaded = await loadCache();
127
+ expect(loaded).not.toBeNull();
128
+ expect(loaded!.timestamp).toBe(now);
129
+ } finally {
130
+ vi.useRealTimers();
131
+ }
132
+ });
96
133
  });
97
134
 
98
135
  describe('getCacheAgeMs', () => {
@@ -111,6 +148,25 @@ describe('getCacheAgeMs', () => {
111
148
  expect(age).toBeGreaterThanOrEqual(0);
112
149
  expect(age).toBeLessThan(100);
113
150
  });
151
+
152
+ it('should clamp small future skew to age 0', () => {
153
+ const cache = createMockCache({ timestamp: Date.now() + 60000 });
154
+ expect(getCacheAgeMs(cache)).toBe(0);
155
+ });
156
+ });
157
+
158
+ describe('formatDuration', () => {
159
+ it('should return unknown for null values', () => {
160
+ expect(formatDuration(null)).toBe('unknown');
161
+ });
162
+
163
+ it('should format zero as less than one minute', () => {
164
+ expect(formatDuration(0)).toBe('<1m');
165
+ });
166
+
167
+ it('should format negative values as less than one minute', () => {
168
+ expect(formatDuration(-1)).toBe('<1m');
169
+ });
114
170
  });
115
171
 
116
172
  describe('formatCacheAge', () => {
@@ -137,4 +193,9 @@ describe('formatCacheAge', () => {
137
193
  const cache = createMockCache({ timestamp: Date.now() - 60 * 60000 }); // exactly 1 hour
138
194
  expect(formatCacheAge(cache)).toBe('1h');
139
195
  });
196
+
197
+ it('should clamp future cache age display to less than one minute', () => {
198
+ const cache = createMockCache({ timestamp: Date.now() + 60000 });
199
+ expect(formatCacheAge(cache)).toBe('<1m');
200
+ });
140
201
  });
@@ -33,6 +33,7 @@ describe('mapOpenRouterModel', () => {
33
33
  }> = [
34
34
  { name: 'empty id', overrides: { id: '' } },
35
35
  { name: 'undefined id (deleted property)', overrides: (m) => delete (m as any).id },
36
+ { name: 'missing pricing object', overrides: { pricing: undefined as any } },
36
37
  { name: 'missing pricing.prompt', overrides: { pricing: { completion: '0.000001' } as any } },
37
38
  { name: 'missing pricing.completion', overrides: { pricing: { prompt: '0.000001' } as any } },
38
39
  {
@@ -213,6 +214,7 @@ describe('mapOpenRouterModels', () => {
213
214
 
214
215
  expect(result.configs).toHaveLength(2);
215
216
  expect(result.skipped).toBe(1);
217
+ expect(result.skippedDetails).toEqual([{ id: 'unknown', reason: 'missing id' }]);
216
218
  expect(result.configs[0]!.id).toBe('model/valid-1');
217
219
  expect(result.configs[1]!.id).toBe('model/valid-2');
218
220
  });
@@ -237,4 +239,31 @@ describe('mapOpenRouterModels', () => {
237
239
  expect(result.configs).toHaveLength(0);
238
240
  expect(result.skipped).toBe(3);
239
241
  });
242
+
243
+ it('should add optional human-readable hints without changing reason codes', async () => {
244
+ const result = await mapOpenRouterModels([
245
+ createValidModel({
246
+ id: 'provider/no-context',
247
+ context_length: 0,
248
+ top_provider: { context_length: 0 },
249
+ }),
250
+ createValidModel({
251
+ id: 'provider/no-pricing',
252
+ pricing: { prompt: '0.000001' } as any,
253
+ }),
254
+ ]);
255
+
256
+ expect(result.skippedDetails).toEqual([
257
+ {
258
+ id: 'provider/no-context',
259
+ reason: 'missing context window',
260
+ hint: expect.stringContaining('contextWindow'),
261
+ },
262
+ {
263
+ id: 'provider/no-pricing',
264
+ reason: 'missing completion pricing',
265
+ hint: expect.stringContaining('cost safely'),
266
+ },
267
+ ]);
268
+ });
240
269
  });