@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.
- package/README.md +87 -4
- 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 +276 -0
- package/extensions/openrouter/__tests__/index.test.ts +163 -0
- 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/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 +229 -0
- package/extensions/openrouter/index.ts +13 -589
- package/extensions/openrouter/local-usage.ts +145 -22
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
- 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__/overrides.test.ts +237 -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 +60 -77
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/overrides.ts +174 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +31 -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/types.ts +28 -9
- package/package.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
loadModelOverrides,
|
|
4
|
+
ModelOverridesLoadError,
|
|
5
|
+
saveModelOverrides,
|
|
6
|
+
setModelOverride,
|
|
7
|
+
removeModelOverride,
|
|
8
|
+
} from '../overrides.js';
|
|
9
|
+
import type { ModelOverridesFile, UserModelOverride } from '../types.js';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
// Mock node:fs
|
|
16
|
+
vi.mock('node:fs', () => ({
|
|
17
|
+
existsSync: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock node:fs/promises
|
|
21
|
+
vi.mock('node:fs/promises', () => ({
|
|
22
|
+
readFile: vi.fn(),
|
|
23
|
+
writeFile: vi.fn(),
|
|
24
|
+
mkdir: vi.fn(),
|
|
25
|
+
rm: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock node:os
|
|
29
|
+
vi.mock('node:os', () => ({
|
|
30
|
+
homedir: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const OVERRIDES_FILE = join('/mock/home', '.pi', 'openrouter', 'model-overrides.json');
|
|
34
|
+
|
|
35
|
+
describe('overrides', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.mocked(homedir).mockReturnValue('/mock/home');
|
|
38
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
39
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
40
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
41
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
42
|
+
vi.mocked(rm).mockResolvedValue(undefined);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('loadModelOverrides', () => {
|
|
50
|
+
it('should return empty overrides when file does not exist', async () => {
|
|
51
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
52
|
+
|
|
53
|
+
const result = await loadModelOverrides();
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({ version: 1, overrides: {} });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should throw when file is invalid JSON', async () => {
|
|
59
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
60
|
+
vi.mocked(readFile).mockResolvedValue('invalid json');
|
|
61
|
+
|
|
62
|
+
await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
|
|
63
|
+
await expect(loadModelOverrides()).rejects.toThrow('Invalid JSON in model overrides file');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should throw when file has wrong structure', async () => {
|
|
67
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
68
|
+
vi.mocked(readFile).mockResolvedValue(JSON.stringify({ foo: 'bar' }));
|
|
69
|
+
|
|
70
|
+
await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
|
|
71
|
+
await expect(loadModelOverrides()).rejects.toThrow('Invalid model overrides file structure');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should throw when existing file cannot be read', async () => {
|
|
75
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
76
|
+
vi.mocked(readFile).mockRejectedValue(new Error('permission denied'));
|
|
77
|
+
|
|
78
|
+
await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
|
|
79
|
+
await expect(loadModelOverrides()).rejects.toThrow('Failed to read model overrides file');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should load valid overrides file', async () => {
|
|
83
|
+
const mockData: ModelOverridesFile = {
|
|
84
|
+
version: 1,
|
|
85
|
+
overrides: {
|
|
86
|
+
'test/model': {
|
|
87
|
+
thinkingLevelMap: { high: 'high', xhigh: 'max' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
92
|
+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockData));
|
|
93
|
+
|
|
94
|
+
const result = await loadModelOverrides();
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual(mockData);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('saveModelOverrides', () => {
|
|
101
|
+
it('should create directory if it does not exist', async () => {
|
|
102
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
103
|
+
|
|
104
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
105
|
+
await saveModelOverrides(overrides);
|
|
106
|
+
|
|
107
|
+
expect(mkdir).toHaveBeenCalledWith(join('/mock/home', '.pi', 'openrouter'), {
|
|
108
|
+
recursive: true,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should write JSON to file', async () => {
|
|
113
|
+
const overrides: ModelOverridesFile = {
|
|
114
|
+
version: 1,
|
|
115
|
+
overrides: {
|
|
116
|
+
'test/model': {
|
|
117
|
+
thinkingLevelMap: { high: 'high' },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
await saveModelOverrides(overrides);
|
|
123
|
+
|
|
124
|
+
expect(writeFile).toHaveBeenCalledWith(
|
|
125
|
+
OVERRIDES_FILE,
|
|
126
|
+
JSON.stringify(overrides, null, 2),
|
|
127
|
+
'utf-8',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('setModelOverride', () => {
|
|
133
|
+
it('should add new override', () => {
|
|
134
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
135
|
+
const override: UserModelOverride = {
|
|
136
|
+
thinkingLevelMap: { high: 'high' },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const result = setModelOverride(overrides, 'test/model', override);
|
|
140
|
+
|
|
141
|
+
expect(result.overrides['test/model']).toEqual({
|
|
142
|
+
thinkingLevelMap: { high: 'high' },
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should merge with existing override', () => {
|
|
147
|
+
const existing: UserModelOverride = {
|
|
148
|
+
thinkingLevelMap: { minimal: null, high: 'low' },
|
|
149
|
+
contextWindow: 128000,
|
|
150
|
+
};
|
|
151
|
+
const overrides: ModelOverridesFile = {
|
|
152
|
+
version: 1,
|
|
153
|
+
overrides: { 'test/model': existing },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const newOverride: UserModelOverride = {
|
|
157
|
+
thinkingLevelMap: { high: 'high' },
|
|
158
|
+
maxTokens: 8192,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const result = setModelOverride(overrides, 'test/model', newOverride);
|
|
162
|
+
|
|
163
|
+
expect(result.overrides['test/model']).toEqual({
|
|
164
|
+
thinkingLevelMap: { minimal: null, high: 'high' },
|
|
165
|
+
contextWindow: 128000,
|
|
166
|
+
maxTokens: 8192,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should clean up undefined thinkingLevelMap entries', () => {
|
|
171
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
172
|
+
const override: UserModelOverride = {
|
|
173
|
+
thinkingLevelMap: { high: 'high', medium: undefined as unknown as null },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const result = setModelOverride(overrides, 'test/model', override);
|
|
177
|
+
|
|
178
|
+
expect(result.overrides['test/model']?.thinkingLevelMap).toEqual({
|
|
179
|
+
high: 'high',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should remove empty thinkingLevelMap', () => {
|
|
184
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
185
|
+
const override: UserModelOverride = {
|
|
186
|
+
thinkingLevelMap: {},
|
|
187
|
+
contextWindow: 128000,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = setModelOverride(overrides, 'test/model', override);
|
|
191
|
+
|
|
192
|
+
expect(result.overrides['test/model']?.thinkingLevelMap).toBeUndefined();
|
|
193
|
+
expect(result.overrides['test/model']?.contextWindow).toBe(128000);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should not create thinkingLevelMap for non-thinking overrides', () => {
|
|
197
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
198
|
+
|
|
199
|
+
const result = setModelOverride(overrides, 'test/model', {
|
|
200
|
+
contextWindow: 64000,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.overrides['test/model']).toEqual({
|
|
204
|
+
contextWindow: 64000,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('removeModelOverride', () => {
|
|
210
|
+
it('should remove existing override', () => {
|
|
211
|
+
const overrides: ModelOverridesFile = {
|
|
212
|
+
version: 1,
|
|
213
|
+
overrides: {
|
|
214
|
+
'test/model': { thinkingLevelMap: { high: 'high' } },
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = removeModelOverride(overrides, 'test/model');
|
|
219
|
+
|
|
220
|
+
expect(result.overrides['test/model']).toBeUndefined();
|
|
221
|
+
expect(Object.keys(result.overrides)).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should be idempotent for non-existent model', () => {
|
|
225
|
+
const overrides: ModelOverridesFile = {
|
|
226
|
+
version: 1,
|
|
227
|
+
overrides: {
|
|
228
|
+
'other/model': { thinkingLevelMap: { high: 'high' } },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const result = removeModelOverride(overrides, 'unknown/model');
|
|
233
|
+
|
|
234
|
+
expect(result).toEqual(overrides);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
6
|
import type { ExtensionContext, ModelRegistry } from '@mariozechner/pi-coding-agent';
|
|
7
|
-
import type { SyncResult } from '../types.js';
|
|
7
|
+
import type { PiModelConfig, SyncResult } from '../types.js';
|
|
8
|
+
import { ROUTER_ALIASES } from '../types.js';
|
|
9
|
+
import { createPiModelConfig, createValidModel } from '../../__tests__/fixtures.js';
|
|
8
10
|
|
|
9
11
|
// Import modules
|
|
10
12
|
import {
|
|
@@ -13,9 +15,10 @@ import {
|
|
|
13
15
|
getSyncState,
|
|
14
16
|
getStatusText,
|
|
15
17
|
areModelsAvailable,
|
|
18
|
+
includeBuiltinRouterModels,
|
|
16
19
|
} from '../sync.js';
|
|
17
20
|
import { fetchUserModels, AuthError } from '../../client.js';
|
|
18
|
-
import { loadCache } from '../cache.js';
|
|
21
|
+
import { loadCache, saveCache } from '../cache.js';
|
|
19
22
|
|
|
20
23
|
// Mock the client module to control API behavior
|
|
21
24
|
vi.mock('../../client.js', () => ({
|
|
@@ -133,8 +136,13 @@ describe('syncModels', () => {
|
|
|
133
136
|
it('should return failure when API key is missing and no cache', async () => {
|
|
134
137
|
// Ensure API key is not set
|
|
135
138
|
delete process.env['OPENROUTER_API_KEY'];
|
|
139
|
+
delete process.env['OPENROUTER_MANAGEMENT_KEY'];
|
|
136
140
|
// Mock fetchUserModels to throw AuthError
|
|
137
|
-
vi.mocked(fetchUserModels).mockRejectedValueOnce(
|
|
141
|
+
vi.mocked(fetchUserModels).mockRejectedValueOnce(
|
|
142
|
+
new AuthError(
|
|
143
|
+
'OpenRouter API key not configured. Set OPENROUTER_API_KEY or OPENROUTER_MANAGEMENT_KEY.',
|
|
144
|
+
),
|
|
145
|
+
);
|
|
138
146
|
// Mock loadCache to return null (no cache available)
|
|
139
147
|
vi.mocked(loadCache).mockResolvedValueOnce(null);
|
|
140
148
|
|
|
@@ -143,7 +151,7 @@ describe('syncModels', () => {
|
|
|
143
151
|
expect(result.success).toBe(false);
|
|
144
152
|
expect(result.registeredCount).toBe(0);
|
|
145
153
|
expect(result.source).toBe('none');
|
|
146
|
-
expect(result.error).toContain('
|
|
154
|
+
expect(result.error).toContain('OpenRouter API key not configured');
|
|
147
155
|
});
|
|
148
156
|
|
|
149
157
|
it('should sync models from API and register with provider', async () => {
|
|
@@ -182,6 +190,150 @@ describe('syncModels', () => {
|
|
|
182
190
|
expect(result.registeredCount).toBeGreaterThan(0);
|
|
183
191
|
expect(mockRegisterProvider).toHaveBeenCalled();
|
|
184
192
|
});
|
|
193
|
+
|
|
194
|
+
it('should register the API user catalog plus router aliases exactly once', async () => {
|
|
195
|
+
const mockModel = {
|
|
196
|
+
id: 'user/model-a',
|
|
197
|
+
name: 'User Model A',
|
|
198
|
+
architecture: {
|
|
199
|
+
inputModalities: ['text'],
|
|
200
|
+
outputModalities: ['text'],
|
|
201
|
+
},
|
|
202
|
+
contextLength: 128000,
|
|
203
|
+
pricing: {
|
|
204
|
+
prompt: 0.000001,
|
|
205
|
+
completion: 0.000002,
|
|
206
|
+
},
|
|
207
|
+
supportedParameters: [],
|
|
208
|
+
topProvider: {
|
|
209
|
+
contextLength: 128000,
|
|
210
|
+
maxCompletionTokens: 4096,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
vi.mocked(fetchUserModels).mockResolvedValueOnce({
|
|
215
|
+
data: [mockModel],
|
|
216
|
+
} as any);
|
|
217
|
+
|
|
218
|
+
const result = await syncModels(mockCtx);
|
|
219
|
+
const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
|
|
220
|
+
const registeredIds = providerConfig.models.map((model) => model.id);
|
|
221
|
+
|
|
222
|
+
expect(result.registeredCount).toBe(1 + ROUTER_ALIASES.length);
|
|
223
|
+
expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
|
|
224
|
+
expect(registeredIds).not.toContain('anthropic/claude-3-opus');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should register cached user models plus router aliases on API failure', async () => {
|
|
228
|
+
vi.mocked(fetchUserModels).mockRejectedValueOnce(new Error('api down'));
|
|
229
|
+
vi.mocked(loadCache).mockResolvedValueOnce({
|
|
230
|
+
models: [createValidModel({ id: 'cached/model-a', name: 'Cached Model A' })],
|
|
231
|
+
skippedDetails: [
|
|
232
|
+
{
|
|
233
|
+
id: 'bad/model',
|
|
234
|
+
reason: 'missing context window',
|
|
235
|
+
hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
timestamp: Date.now() - 60000,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const result = await syncModels(mockCtx);
|
|
242
|
+
const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
|
|
243
|
+
const registeredIds = providerConfig.models.map((model) => model.id);
|
|
244
|
+
|
|
245
|
+
expect(result.source).toBe('cache');
|
|
246
|
+
expect(result.registeredCount).toBe(1 + ROUTER_ALIASES.length);
|
|
247
|
+
expect(registeredIds).toEqual(['cached/model-a', ...ROUTER_ALIASES]);
|
|
248
|
+
expect(result.skippedDetails).toEqual([
|
|
249
|
+
{
|
|
250
|
+
id: 'bad/model',
|
|
251
|
+
reason: 'missing context window',
|
|
252
|
+
hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
|
|
253
|
+
},
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should persist skipped reason hints in the saved cache', async () => {
|
|
258
|
+
vi.mocked(fetchUserModels).mockResolvedValueOnce({
|
|
259
|
+
data: [
|
|
260
|
+
{
|
|
261
|
+
id: 'user/model-a',
|
|
262
|
+
name: 'User Model A',
|
|
263
|
+
architecture: {
|
|
264
|
+
inputModalities: ['text'],
|
|
265
|
+
outputModalities: ['text'],
|
|
266
|
+
},
|
|
267
|
+
contextLength: 128000,
|
|
268
|
+
pricing: {
|
|
269
|
+
prompt: 0.000001,
|
|
270
|
+
completion: 0.000002,
|
|
271
|
+
},
|
|
272
|
+
supportedParameters: [],
|
|
273
|
+
topProvider: {
|
|
274
|
+
contextLength: 128000,
|
|
275
|
+
maxCompletionTokens: 4096,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 'bad/model',
|
|
280
|
+
name: 'Bad Model',
|
|
281
|
+
architecture: {
|
|
282
|
+
inputModalities: ['text'],
|
|
283
|
+
outputModalities: ['text'],
|
|
284
|
+
},
|
|
285
|
+
contextLength: 0,
|
|
286
|
+
pricing: {
|
|
287
|
+
prompt: 0.000001,
|
|
288
|
+
completion: 0.000002,
|
|
289
|
+
},
|
|
290
|
+
supportedParameters: [],
|
|
291
|
+
topProvider: {
|
|
292
|
+
contextLength: 0,
|
|
293
|
+
maxCompletionTokens: 4096,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
} as any);
|
|
298
|
+
|
|
299
|
+
const result = await syncModels(mockCtx);
|
|
300
|
+
|
|
301
|
+
expect(result.skippedDetails).toEqual([
|
|
302
|
+
{
|
|
303
|
+
id: 'bad/model',
|
|
304
|
+
reason: 'missing context window',
|
|
305
|
+
hint: expect.stringContaining(
|
|
306
|
+
'/openrouter model-override-set <model-id> contextWindow=<tokens>',
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
]);
|
|
310
|
+
expect(vi.mocked(saveCache)).toHaveBeenCalledWith(
|
|
311
|
+
expect.objectContaining({
|
|
312
|
+
skippedDetails: [
|
|
313
|
+
{
|
|
314
|
+
id: 'bad/model',
|
|
315
|
+
reason: 'missing context window',
|
|
316
|
+
hint: expect.stringContaining(
|
|
317
|
+
'/openrouter model-override-set <model-id> contextWindow=<tokens>',
|
|
318
|
+
),
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should include router aliases at most once', () => {
|
|
326
|
+
const configs = includeBuiltinRouterModels([
|
|
327
|
+
createPiModelConfig({ id: 'user/model-a' }),
|
|
328
|
+
createPiModelConfig({ id: ROUTER_ALIASES[0]! }),
|
|
329
|
+
]);
|
|
330
|
+
const registeredIds = configs.map((model) => model.id);
|
|
331
|
+
|
|
332
|
+
expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
|
|
333
|
+
for (const routerId of ROUTER_ALIASES) {
|
|
334
|
+
expect(registeredIds.filter((id) => id === routerId)).toHaveLength(1);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
185
337
|
});
|
|
186
338
|
|
|
187
339
|
describe('syncState management', () => {
|
|
@@ -6,6 +6,7 @@ import { MS_PER_MINUTE } from './types.js';
|
|
|
6
6
|
|
|
7
7
|
const CACHE_FILENAME = 'models-cache.json';
|
|
8
8
|
const DEFAULT_CACHE_DIR = join(homedir(), '.pi', 'openrouter');
|
|
9
|
+
const MAX_FUTURE_SKEW_MS = 5 * MS_PER_MINUTE;
|
|
9
10
|
|
|
10
11
|
// Allow overriding cache directory for testing
|
|
11
12
|
let cacheDirOverride: string | null = null;
|
|
@@ -40,6 +41,21 @@ async function ensureCacheDir(): Promise<void> {
|
|
|
40
41
|
await mkdir(getCacheDir(), { recursive: true });
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Returns true when a cache timestamp is structurally valid and not too far in the future.
|
|
46
|
+
*/
|
|
47
|
+
function isValidCacheTimestamp(timestamp: number, now = Date.now()): boolean {
|
|
48
|
+
return Number.isFinite(timestamp) && timestamp <= now + MAX_FUTURE_SKEW_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clamp future timestamps when saving so new cache writes never persist negative ages.
|
|
53
|
+
*/
|
|
54
|
+
function normalizeTimestampForSave(timestamp: number, now = Date.now()): number {
|
|
55
|
+
if (!isValidCacheTimestamp(timestamp, now)) return now;
|
|
56
|
+
return Math.min(timestamp, now);
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
/**
|
|
44
60
|
* Load cached models from disk.
|
|
45
61
|
* Returns null if cache doesn't exist or is corrupted.
|
|
@@ -55,6 +71,10 @@ export async function loadCache(): Promise<ModelsCache | null> {
|
|
|
55
71
|
return null;
|
|
56
72
|
}
|
|
57
73
|
|
|
74
|
+
if (!isValidCacheTimestamp(parsed.timestamp)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
return parsed;
|
|
59
79
|
} catch {
|
|
60
80
|
// File doesn't exist, permission error, or invalid JSON
|
|
@@ -68,14 +88,18 @@ export async function loadCache(): Promise<ModelsCache | null> {
|
|
|
68
88
|
export async function saveCache(cache: ModelsCache): Promise<void> {
|
|
69
89
|
await ensureCacheDir();
|
|
70
90
|
const cachePath = getCachePath();
|
|
71
|
-
|
|
91
|
+
const normalizedCache: ModelsCache = {
|
|
92
|
+
...cache,
|
|
93
|
+
timestamp: normalizeTimestampForSave(cache.timestamp),
|
|
94
|
+
};
|
|
95
|
+
await writeFile(cachePath, JSON.stringify(normalizedCache, null, 2));
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
/**
|
|
75
99
|
* Get the age of the cache in milliseconds.
|
|
76
100
|
*/
|
|
77
101
|
export function getCacheAgeMs(cache: ModelsCache): number {
|
|
78
|
-
return Date.now() - cache.timestamp;
|
|
102
|
+
return Math.max(0, Date.now() - cache.timestamp);
|
|
79
103
|
}
|
|
80
104
|
|
|
81
105
|
/**
|
|
@@ -84,6 +108,7 @@ export function getCacheAgeMs(cache: ModelsCache): number {
|
|
|
84
108
|
*/
|
|
85
109
|
export function formatDuration(ms: number | null): string {
|
|
86
110
|
if (ms === null) return 'unknown';
|
|
111
|
+
if (ms <= 0) return '<1m';
|
|
87
112
|
|
|
88
113
|
const minutes = Math.floor(ms / MS_PER_MINUTE);
|
|
89
114
|
if (minutes < 1) return '<1m';
|