@robhowley/pi-openrouter 0.9.0 → 0.10.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.
- package/README.md +46 -5
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +529 -0
- package/extensions/openrouter/__tests__/index.test.ts +112 -363
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/__tests__/status-bar.test.ts +262 -0
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +288 -0
- package/extensions/openrouter/index.ts +13 -990
- package/extensions/openrouter/local-usage.ts +158 -30
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +35 -69
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +2 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/status-bar.ts +101 -0
- package/extensions/openrouter/types.ts +28 -9
- 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 {
|
|
5
|
+
import { createZeroAggregate } from './types.js';
|
|
6
6
|
|
|
7
7
|
export type { LocalUsageEvent };
|
|
8
8
|
|
|
9
|
-
const
|
|
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
|
|
93
|
+
const usageDir = getLocalUsageDir();
|
|
94
|
+
const filePath = path.join(usageDir, `${dateStr}.jsonl`);
|
|
57
95
|
|
|
58
|
-
await fs.mkdir(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -112,15 +165,9 @@ export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<Lo
|
|
|
112
165
|
}
|
|
113
166
|
|
|
114
167
|
/**
|
|
115
|
-
*
|
|
116
|
-
* Deduplicates by id (first occurrence wins).
|
|
168
|
+
* Deduplicate local usage events by id (first occurrence wins).
|
|
117
169
|
*/
|
|
118
|
-
export function
|
|
119
|
-
if (events.length === 0) {
|
|
120
|
-
return ZERO_AGGREGATE;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Deduplicate by id
|
|
170
|
+
export function dedupeLocalUsageEvents(events: LocalUsageEvent[]): LocalUsageEvent[] {
|
|
124
171
|
const seen = new Set<string>();
|
|
125
172
|
const unique: LocalUsageEvent[] = [];
|
|
126
173
|
|
|
@@ -130,19 +177,100 @@ export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
|
|
|
130
177
|
unique.push(event);
|
|
131
178
|
}
|
|
132
179
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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) => {
|
|
193
|
+
acc.requests += event.requests ?? 1;
|
|
194
|
+
acc.promptTokens += event.promptTokens || 0;
|
|
195
|
+
acc.completionTokens += event.completionTokens || 0;
|
|
196
|
+
acc.reasoningTokens += event.reasoningTokens || 0;
|
|
197
|
+
acc.cacheReadTokens += event.cacheReadTokens || 0;
|
|
198
|
+
acc.cacheWriteTokens += event.cacheWriteTokens || 0;
|
|
199
|
+
acc.cost += event.cost || 0;
|
|
200
|
+
return acc;
|
|
201
|
+
}, createZeroAggregate());
|
|
147
202
|
return result;
|
|
148
203
|
}
|
|
204
|
+
|
|
205
|
+
export interface CleanupOptions {
|
|
206
|
+
/** Number of days to retain (default: 90) */
|
|
207
|
+
retentionDays?: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Delete local usage files older than the retention window.
|
|
212
|
+
*
|
|
213
|
+
* This is called opportunistically after writes and fails open.
|
|
214
|
+
* Errors are logged only when debug flag is enabled.
|
|
215
|
+
*
|
|
216
|
+
* @param options - Cleanup configuration
|
|
217
|
+
* @param options.retentionDays - Days to retain (default: 90)
|
|
218
|
+
*/
|
|
219
|
+
export async function cleanupOldUsageFiles(options: CleanupOptions = {}): Promise<void> {
|
|
220
|
+
const retentionDays = options.retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
221
|
+
const usageDir = getLocalUsageDir();
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// Calculate cutoff date
|
|
225
|
+
const today = getCurrentUtcDate();
|
|
226
|
+
const cutoffDate = addUtcDays(today, -retentionDays);
|
|
227
|
+
|
|
228
|
+
// List all files in usage directory
|
|
229
|
+
const files = await fs.readdir(usageDir);
|
|
230
|
+
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
|
|
231
|
+
|
|
232
|
+
let deletedCount = 0;
|
|
233
|
+
|
|
234
|
+
for (const filename of jsonlFiles) {
|
|
235
|
+
try {
|
|
236
|
+
// Extract date from filename (YYYY-MM-DD.jsonl)
|
|
237
|
+
const dateStr = filename.replace('.jsonl', '');
|
|
238
|
+
|
|
239
|
+
// Validate date format and value
|
|
240
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|
241
|
+
// Skip malformed filenames
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const fileDate = new Date(dateStr + 'T00:00:00Z');
|
|
246
|
+
if (isNaN(fileDate.getTime())) {
|
|
247
|
+
// Skip invalid dates
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Delete if older than cutoff
|
|
252
|
+
if (dateStr < cutoffDate) {
|
|
253
|
+
const filePath = path.join(usageDir, filename);
|
|
254
|
+
await fs.unlink(filePath);
|
|
255
|
+
deletedCount++;
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
// Skip individual file errors
|
|
259
|
+
if (isDebugEnabled()) {
|
|
260
|
+
console.error(`[local-usage] Failed to delete ${filename}:`, err);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (isDebugEnabled() && deletedCount > 0) {
|
|
266
|
+
console.log(
|
|
267
|
+
`[local-usage] Cleaned up ${deletedCount} old usage file${deletedCount === 1 ? '' : 's'}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
// Fail open on directory read errors
|
|
272
|
+
if (isDebugEnabled()) {
|
|
273
|
+
console.error('[local-usage] Failed to cleanup old usage files:', err);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -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 {
|
|
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
|
});
|