@robhowley/pi-openrouter 0.7.1 → 0.8.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.
- package/README.md +43 -3
- package/extensions/openrouter/__tests__/client.test.ts +169 -0
- package/extensions/openrouter/__tests__/fixtures.ts +134 -0
- package/extensions/openrouter/__tests__/format.test.ts +25 -59
- package/extensions/openrouter/__tests__/session.test.ts +1 -81
- package/extensions/openrouter/client.ts +70 -12
- package/extensions/openrouter/index.ts +175 -7
- package/extensions/openrouter/models/__tests__/cache.test.ts +140 -0
- package/extensions/openrouter/models/__tests__/mapper.test.ts +221 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +313 -0
- package/extensions/openrouter/models/cache.ts +105 -0
- package/extensions/openrouter/models/mapper.ts +182 -0
- package/extensions/openrouter/models/sync.ts +310 -0
- package/extensions/openrouter/models/types.ts +148 -0
- package/package.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ActivityResponse } from '@openrouter/sdk/models/index.js';
|
|
2
2
|
import type { GetCreditsResponse } from '@openrouter/sdk/models/operations/index.js';
|
|
3
3
|
import { OpenRouter } from '@openrouter/sdk/sdk/sdk.js';
|
|
4
|
+
import type { ModelsListResponse } from '@openrouter/sdk/models/index.js';
|
|
5
|
+
import { UnauthorizedResponseError } from '@openrouter/sdk/models/errors/index.js';
|
|
4
6
|
|
|
5
7
|
let client: OpenRouter | null = null;
|
|
6
8
|
|
|
@@ -34,23 +36,76 @@ export async function getActivity(): Promise<ActivityResponse['data'] | null> {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Fetch the authenticated user's model catalog from OpenRouter.
|
|
41
|
+
* Uses the SDK for consistent error handling and retry behavior.
|
|
42
|
+
*/
|
|
43
|
+
export async function fetchUserModels(): Promise<ModelsListResponse> {
|
|
44
|
+
const key = getApiKey();
|
|
45
|
+
if (!key) {
|
|
46
|
+
throw new AuthError('OPENROUTER_API_KEY not set');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const sdkClient = new OpenRouter({ apiKey: key });
|
|
51
|
+
const response = await sdkClient.models.listForUser({ bearer: key }, {});
|
|
52
|
+
return response as ModelsListResponse;
|
|
53
|
+
} catch (err: unknown) {
|
|
54
|
+
throw mapSdkError(err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if the OpenRouter API key is configured.
|
|
60
|
+
*/
|
|
61
|
+
export function isConfigured(): boolean {
|
|
62
|
+
return !!getApiKey();
|
|
40
63
|
}
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Get the OpenRouter API key from environment.
|
|
67
|
+
*/
|
|
68
|
+
export function getApiKey(): string | undefined {
|
|
69
|
+
return process.env['OPENROUTER_API_KEY'];
|
|
44
70
|
}
|
|
45
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Map SDK errors to our error types with proper status codes.
|
|
74
|
+
*/
|
|
46
75
|
function mapSdkError(err: unknown): Error {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (status === 401) return new AuthError(message);
|
|
51
|
-
return new ApiError(`${status}: ${message}`);
|
|
76
|
+
// Handle UnauthorizedResponseError (401)
|
|
77
|
+
if (err instanceof UnauthorizedResponseError) {
|
|
78
|
+
return new ApiError('Unauthorized: Invalid or expired API key', 401);
|
|
52
79
|
}
|
|
53
|
-
|
|
80
|
+
|
|
81
|
+
// Handle other SDK errors with statusCode
|
|
82
|
+
if (err instanceof Error && 'statusCode' in err) {
|
|
83
|
+
const statusCode = (err as { statusCode: number }).statusCode;
|
|
84
|
+
if (statusCode === 401) {
|
|
85
|
+
return new AuthError(err.message || 'Unauthorized');
|
|
86
|
+
}
|
|
87
|
+
return new ApiError(err.message || 'API error', statusCode);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Map error messages to appropriate status codes
|
|
91
|
+
if (err instanceof Error) {
|
|
92
|
+
const message = err.message.toLowerCase();
|
|
93
|
+
if (message.includes('unauthorized')) {
|
|
94
|
+
return new ApiError('Unauthorized: Invalid or expired API key', 401);
|
|
95
|
+
}
|
|
96
|
+
if (message.includes('rate limit') || message.includes('rate limited')) {
|
|
97
|
+
return new ApiError('Rate limited: Too many requests', 429);
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
message.includes('server error') ||
|
|
101
|
+
message.includes('internal') ||
|
|
102
|
+
message.includes('service unavailable')
|
|
103
|
+
) {
|
|
104
|
+
return new ApiError(err.message || 'Server error', 500);
|
|
105
|
+
}
|
|
106
|
+
return new ApiError(err.message || 'API error', 500);
|
|
107
|
+
}
|
|
108
|
+
|
|
54
109
|
return new Error(String(err));
|
|
55
110
|
}
|
|
56
111
|
|
|
@@ -62,7 +117,10 @@ export class AuthError extends Error {
|
|
|
62
117
|
}
|
|
63
118
|
|
|
64
119
|
export class ApiError extends Error {
|
|
65
|
-
constructor(
|
|
120
|
+
constructor(
|
|
121
|
+
message: string,
|
|
122
|
+
public readonly statusCode?: number,
|
|
123
|
+
) {
|
|
66
124
|
super(message);
|
|
67
125
|
this.name = 'ApiError';
|
|
68
126
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
2
2
|
import type { UsageSummary } from './types.js';
|
|
3
|
+
import { MS_PER_MINUTE } from './models/types.js';
|
|
3
4
|
import {
|
|
4
5
|
usageCache,
|
|
5
6
|
startBackgroundRefresh,
|
|
@@ -17,10 +18,51 @@ import type { KeyInfo } from './account-types.js';
|
|
|
17
18
|
import type { RollupStatus } from './account-types.js';
|
|
18
19
|
import crypto from 'node:crypto';
|
|
19
20
|
|
|
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
|
+
|
|
20
32
|
// Store the current session state for use in command handlers
|
|
21
33
|
let currentSessionState: OpenRouterSessionState | null = null;
|
|
22
34
|
let sessionTrackingInstalled = false;
|
|
23
35
|
|
|
36
|
+
// Store startup cache state for notifications
|
|
37
|
+
let startupCacheInfo: { count: number; age: string } | undefined;
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Utility Functions
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format skipped models details for --skipped flag output.
|
|
45
|
+
*/
|
|
46
|
+
function formatSkippedDetails(
|
|
47
|
+
skipCount: number,
|
|
48
|
+
groupedReasons: Record<string, number>,
|
|
49
|
+
skipReasons: Array<{ id: string; reason: string }>,
|
|
50
|
+
): string {
|
|
51
|
+
if (skipCount === 0) {
|
|
52
|
+
return '\n\nNo skipped models';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let details = `\n\nOpenRouter skipped models: ${skipCount}\n`;
|
|
56
|
+
for (const [reason, count] of Object.entries(groupedReasons)) {
|
|
57
|
+
details += `\n${count} ${reason}\n`;
|
|
58
|
+
const modelsWithReason = skipReasons.filter((r) => r.reason === reason).map((r) => r.id);
|
|
59
|
+
for (const id of modelsWithReason) {
|
|
60
|
+
details += `- ${id}\n`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return details;
|
|
64
|
+
}
|
|
65
|
+
|
|
24
66
|
// =============================================================================
|
|
25
67
|
// Session State Management
|
|
26
68
|
// =============================================================================
|
|
@@ -49,7 +91,36 @@ function getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }
|
|
|
49
91
|
}
|
|
50
92
|
}
|
|
51
93
|
|
|
52
|
-
export default function (pi: ExtensionAPI) {
|
|
94
|
+
export default async function (pi: ExtensionAPI) {
|
|
95
|
+
// Eager cache load on extension startup (before any sessions)
|
|
96
|
+
startupCacheInfo = undefined;
|
|
97
|
+
let startupCacheWarning: string | undefined;
|
|
98
|
+
if (isSyncEnabled()) {
|
|
99
|
+
const cache = await loadCache().catch(() => null);
|
|
100
|
+
|
|
101
|
+
if (cache?.models.length) {
|
|
102
|
+
try {
|
|
103
|
+
const { configs } = mapOpenRouterModels(cache.models);
|
|
104
|
+
|
|
105
|
+
// Register models directly with Pi's OpenRouter provider
|
|
106
|
+
pi.registerProvider('openrouter', {
|
|
107
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
108
|
+
apiKey: 'OPENROUTER_API_KEY',
|
|
109
|
+
api: 'openai-completions',
|
|
110
|
+
models: configs,
|
|
111
|
+
authHeader: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Store for session_start notification
|
|
115
|
+
const age = formatDuration(getCacheAgeMs(cache));
|
|
116
|
+
startupCacheInfo = { count: configs.length, age };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
startupCacheInfo = undefined;
|
|
119
|
+
startupCacheWarning = `OpenRouter: cached models found but failed to register: ${error instanceof Error ? error.message : String(error)}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
53
124
|
// Install before_provider_request hook once
|
|
54
125
|
if (!sessionTrackingInstalled) {
|
|
55
126
|
sessionTrackingInstalled = true;
|
|
@@ -160,6 +231,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
160
231
|
stopBackgroundRefresh();
|
|
161
232
|
});
|
|
162
233
|
|
|
234
|
+
// Notify on first session start after extension load
|
|
235
|
+
pi.on('session_start', (event, ctx) => {
|
|
236
|
+
if (!ctx.hasUI) return;
|
|
237
|
+
|
|
238
|
+
// Show a persistent status indicator
|
|
239
|
+
if (startupCacheInfo) {
|
|
240
|
+
const statusText = `OpenRouter ${startupCacheInfo.count} models`;
|
|
241
|
+
ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusText));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Show a one-time notification on startup
|
|
245
|
+
if (event.reason === 'startup' && startupCacheInfo) {
|
|
246
|
+
const notice = `OpenRouter: ${startupCacheInfo.count} models loaded from cache (${startupCacheInfo.age} old). Run /openrouter models-sync to refresh.`;
|
|
247
|
+
ctx.ui.notify(notice, 'info');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (event.reason === 'startup' && startupCacheWarning) {
|
|
251
|
+
ctx.ui.notify(startupCacheWarning, 'warning');
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
163
255
|
pi.registerCommand('openrouter-usage', {
|
|
164
256
|
description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
|
|
165
257
|
getArgumentCompletions: () => null,
|
|
@@ -187,18 +279,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
187
279
|
},
|
|
188
280
|
});
|
|
189
281
|
|
|
190
|
-
//
|
|
282
|
+
// ============== MODELS COMMANDS (subcommands of /openrouter) ==============
|
|
283
|
+
|
|
284
|
+
// Single entry point with subcommands: /openrouter [usage|account|session|models-sync|models-status]
|
|
191
285
|
pi.registerCommand('openrouter', {
|
|
192
|
-
description: 'OpenRouter commands: usage, account, session',
|
|
286
|
+
description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
|
|
193
287
|
getArgumentCompletions: (prefix: string) => {
|
|
194
|
-
const subcommands = ['usage', 'account', 'session'];
|
|
288
|
+
const subcommands = ['usage', 'account', 'session', 'models-sync', 'models-status'];
|
|
195
289
|
const items = subcommands
|
|
196
290
|
.filter((s) => s.startsWith(prefix))
|
|
197
291
|
.map((s) => ({ value: s, label: s }));
|
|
198
292
|
return items.length > 0 ? items : null;
|
|
199
293
|
},
|
|
200
294
|
handler: async (args, ctx) => {
|
|
201
|
-
|
|
295
|
+
// Parse subcommand and flags
|
|
296
|
+
const parts = args.trim().split(/\s+/);
|
|
297
|
+
const subcommand = parts[0] || '';
|
|
298
|
+
const flags = parts.slice(1).reduce(
|
|
299
|
+
(acc, flag) => {
|
|
300
|
+
acc[flag] = true;
|
|
301
|
+
return acc;
|
|
302
|
+
},
|
|
303
|
+
{} as Record<string, boolean>,
|
|
304
|
+
);
|
|
202
305
|
|
|
203
306
|
switch (subcommand) {
|
|
204
307
|
case 'usage': {
|
|
@@ -214,8 +317,73 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
317
|
ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
|
|
215
318
|
break;
|
|
216
319
|
}
|
|
320
|
+
case 'models-sync': {
|
|
321
|
+
if (!isSyncEnabled()) {
|
|
322
|
+
ctx.ui.notify(
|
|
323
|
+
'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
|
|
324
|
+
'error',
|
|
325
|
+
);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const result = await syncModels(ctx);
|
|
329
|
+
|
|
330
|
+
// Display brief result using same color scheme as overlays
|
|
331
|
+
if (!result.success) {
|
|
332
|
+
let message = '';
|
|
333
|
+
if (result.source === 'cache') {
|
|
334
|
+
message = `OpenRouter models sync failed\n${result.registeredCount} registered from cache\nCache age: ${formatDuration(result.cacheAgeMs)}\nError: ${result.error}`;
|
|
335
|
+
} else {
|
|
336
|
+
message = `OpenRouter models unavailable\n0 registered\nError: ${result.error}`;
|
|
337
|
+
}
|
|
338
|
+
ctx.ui.notify(message, result.source === 'cache' ? 'warning' : 'error');
|
|
339
|
+
} else {
|
|
340
|
+
const message = `OpenRouter models synced\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache updated`;
|
|
341
|
+
ctx.ui.notify(message, 'info');
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case 'models-status': {
|
|
346
|
+
const state = getSyncState();
|
|
347
|
+
const skipReasons = await getSkipReasonsAsync();
|
|
348
|
+
const groupedReasons = groupSkipReasons(skipReasons);
|
|
349
|
+
|
|
350
|
+
// Get real-time cache age from disk
|
|
351
|
+
const cache = await loadCache();
|
|
352
|
+
const cacheAgeMs = cache ? getCacheAgeMs(cache) : null;
|
|
353
|
+
|
|
354
|
+
if (!state && !cache) {
|
|
355
|
+
ctx.ui.notify('OpenRouter models: not synced', 'error');
|
|
356
|
+
} else if (!state && cache) {
|
|
357
|
+
// Cache exists but no in-memory state (new Pi session)
|
|
358
|
+
const cachedCount = cache.models.length;
|
|
359
|
+
const message = `OpenRouter models cached\n${cachedCount} models in cache · age: ${formatDuration(cacheAgeMs)}\nRun '/openrouter models-sync' to register models`;
|
|
360
|
+
ctx.ui.notify(message, 'info');
|
|
361
|
+
} else if (state?.success) {
|
|
362
|
+
const skipCount = skipReasons.length;
|
|
363
|
+
let message = `OpenRouter models healthy\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''} · cache age: ${formatDuration(cacheAgeMs)}`;
|
|
364
|
+
|
|
365
|
+
if (flags['--skipped']) {
|
|
366
|
+
message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
|
|
367
|
+
}
|
|
368
|
+
ctx.ui.notify(message, 'info');
|
|
369
|
+
} else if (state?.source === 'cache') {
|
|
370
|
+
const skipCount = skipReasons.length;
|
|
371
|
+
let message = `OpenRouter models cached\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''}\nCache age: ${formatDuration(cacheAgeMs)}\nError: ${state.error}`;
|
|
372
|
+
|
|
373
|
+
if (flags['--skipped']) {
|
|
374
|
+
message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
|
|
375
|
+
}
|
|
376
|
+
ctx.ui.notify(message, 'warning');
|
|
377
|
+
} else {
|
|
378
|
+
ctx.ui.notify(
|
|
379
|
+
`OpenRouter models broken\n0 registered\nError: ${state?.error}`,
|
|
380
|
+
'error',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
217
385
|
default: {
|
|
218
|
-
const available = ['usage', 'account', 'session'];
|
|
386
|
+
const available = ['usage', 'account', 'session', 'models-sync', 'models-status'];
|
|
219
387
|
const message =
|
|
220
388
|
available.length > 0
|
|
221
389
|
? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
|
|
@@ -358,7 +526,7 @@ async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
|
|
|
358
526
|
const cachedSummary = usageCache.get('usage');
|
|
359
527
|
const lastFetchTimestamp = usageCache.getTimestamp('usage');
|
|
360
528
|
const cachedMinutesAgo = lastFetchTimestamp
|
|
361
|
-
? Math.round((Date.now() - lastFetchTimestamp) /
|
|
529
|
+
? Math.round((Date.now() - lastFetchTimestamp) / MS_PER_MINUTE)
|
|
362
530
|
: null;
|
|
363
531
|
|
|
364
532
|
if (cachedSummary) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, rm, writeFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import { loadCache, saveCache, getCacheAgeMs, formatCacheAge, setCacheDir } from '../cache.js';
|
|
7
|
+
import { createMockCache } from '../../__tests__/fixtures.js';
|
|
8
|
+
|
|
9
|
+
// Each test gets its own isolated temp directory
|
|
10
|
+
let testCacheDir: string;
|
|
11
|
+
|
|
12
|
+
async function setupTestCache(): Promise<void> {
|
|
13
|
+
// Create isolated temp directory for this test
|
|
14
|
+
testCacheDir = join(tmpdir(), `pi-openrouter-test-${randomUUID()}`);
|
|
15
|
+
await mkdir(testCacheDir, { recursive: true });
|
|
16
|
+
// Tell the cache module to use our test directory
|
|
17
|
+
setCacheDir(testCacheDir);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function cleanupTestCache(): Promise<void> {
|
|
21
|
+
// Reset cache dir to default (null means use default)
|
|
22
|
+
setCacheDir(null);
|
|
23
|
+
// Clean up temp directory
|
|
24
|
+
try {
|
|
25
|
+
await rm(testCacheDir, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
// Ignore cleanup errors
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('loadCache', () => {
|
|
32
|
+
beforeEach(setupTestCache);
|
|
33
|
+
afterEach(cleanupTestCache);
|
|
34
|
+
|
|
35
|
+
it('should return null when cache file does not exist', async () => {
|
|
36
|
+
const result = await loadCache();
|
|
37
|
+
expect(result).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return parsed cache when file exists and is valid', async () => {
|
|
41
|
+
const mockCache = createMockCache({ timestamp: 1234567890 });
|
|
42
|
+
await saveCache(mockCache);
|
|
43
|
+
|
|
44
|
+
const result = await loadCache();
|
|
45
|
+
expect(result).not.toBeNull();
|
|
46
|
+
expect(result!.timestamp).toBe(1234567890);
|
|
47
|
+
expect(result!.models).toHaveLength(1);
|
|
48
|
+
expect(result!.models[0]!.id).toBe('test/model');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return null when cache file contains invalid JSON', async () => {
|
|
52
|
+
await mkdir(testCacheDir, { recursive: true });
|
|
53
|
+
const cacheFile = join(testCacheDir, 'models-cache.json');
|
|
54
|
+
await writeFile(cacheFile, 'not valid json');
|
|
55
|
+
|
|
56
|
+
const result = await loadCache();
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return null when cache structure is invalid', async () => {
|
|
61
|
+
await mkdir(testCacheDir, { recursive: true });
|
|
62
|
+
const cacheFile = join(testCacheDir, 'models-cache.json');
|
|
63
|
+
await writeFile(cacheFile, JSON.stringify({ timestamp: 1234 })); // missing models
|
|
64
|
+
|
|
65
|
+
const result = await loadCache();
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('saveCache', () => {
|
|
71
|
+
beforeEach(setupTestCache);
|
|
72
|
+
afterEach(cleanupTestCache);
|
|
73
|
+
|
|
74
|
+
it('should create cache file with valid JSON', async () => {
|
|
75
|
+
const mockCache = createMockCache();
|
|
76
|
+
|
|
77
|
+
await saveCache(mockCache);
|
|
78
|
+
|
|
79
|
+
// Verify it can be loaded back
|
|
80
|
+
const loaded = await loadCache();
|
|
81
|
+
expect(loaded).not.toBeNull();
|
|
82
|
+
expect(loaded!.timestamp).toBe(mockCache.timestamp);
|
|
83
|
+
expect(loaded!.models).toEqual(mockCache.models);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should overwrite existing cache file', async () => {
|
|
87
|
+
const firstCache = createMockCache({ timestamp: 1000 });
|
|
88
|
+
const secondCache = createMockCache({ timestamp: 2000 });
|
|
89
|
+
|
|
90
|
+
await saveCache(firstCache);
|
|
91
|
+
await saveCache(secondCache);
|
|
92
|
+
|
|
93
|
+
const loaded = await loadCache();
|
|
94
|
+
expect(loaded!.timestamp).toBe(2000);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('getCacheAgeMs', () => {
|
|
99
|
+
it('should calculate age correctly for recent cache', () => {
|
|
100
|
+
const cache = createMockCache({ timestamp: Date.now() - 60000 }); // 1 minute ago
|
|
101
|
+
const age = getCacheAgeMs(cache);
|
|
102
|
+
|
|
103
|
+
// Allow 100ms tolerance for test execution time
|
|
104
|
+
expect(age).toBeGreaterThanOrEqual(60000);
|
|
105
|
+
expect(age).toBeLessThan(61000);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return 0 for cache with current timestamp', () => {
|
|
109
|
+
const cache = createMockCache({ timestamp: Date.now() });
|
|
110
|
+
const age = getCacheAgeMs(cache);
|
|
111
|
+
expect(age).toBeGreaterThanOrEqual(0);
|
|
112
|
+
expect(age).toBeLessThan(100);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('formatCacheAge', () => {
|
|
117
|
+
it('should return null for null cache', () => {
|
|
118
|
+
expect(formatCacheAge(null)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should format minutes when less than 1 hour', () => {
|
|
122
|
+
const cache = createMockCache({ timestamp: Date.now() - 4 * 60000 }); // 4 minutes
|
|
123
|
+
expect(formatCacheAge(cache)).toBe('4m');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should format hours when between 1 hour and 1 day', () => {
|
|
127
|
+
const cache = createMockCache({ timestamp: Date.now() - 2 * 60 * 60000 }); // 2 hours
|
|
128
|
+
expect(formatCacheAge(cache)).toBe('2h');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should format days when over 1 day', () => {
|
|
132
|
+
const cache = createMockCache({ timestamp: Date.now() - 25 * 60 * 60000 }); // 25 hours
|
|
133
|
+
expect(formatCacheAge(cache)).toBe('1d');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle exact hour boundaries', () => {
|
|
137
|
+
const cache = createMockCache({ timestamp: Date.now() - 60 * 60000 }); // exactly 1 hour
|
|
138
|
+
expect(formatCacheAge(cache)).toBe('1h');
|
|
139
|
+
});
|
|
140
|
+
});
|