@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.
- package/README.md +39 -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 +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/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 -990
- package/extensions/openrouter/local-usage.ts +145 -22
- 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/types.ts +28 -9
- package/package.json +1 -1
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mocks, overlayConstructorCalls, MockUsageOverlayComponent } = vi.hoisted(() => {
|
|
4
|
+
const overlayConstructorCalls: Array<{
|
|
5
|
+
summary: any;
|
|
6
|
+
error: any;
|
|
7
|
+
cachedMinutesAgo: any;
|
|
8
|
+
}> = [];
|
|
9
|
+
|
|
10
|
+
class MockUsageOverlayComponent {
|
|
11
|
+
constructor(
|
|
12
|
+
public summary: any,
|
|
13
|
+
public error: any,
|
|
14
|
+
public cachedMinutesAgo: any,
|
|
15
|
+
public theme: any,
|
|
16
|
+
public done: any,
|
|
17
|
+
public requestRender: any,
|
|
18
|
+
) {
|
|
19
|
+
overlayConstructorCalls.push({ summary, error, cachedMinutesAgo });
|
|
20
|
+
}
|
|
21
|
+
handleInput = vi.fn();
|
|
22
|
+
render = vi.fn(() => '');
|
|
23
|
+
invalidate = vi.fn();
|
|
24
|
+
dispose = vi.fn();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
overlayConstructorCalls,
|
|
29
|
+
MockUsageOverlayComponent,
|
|
30
|
+
mocks: {
|
|
31
|
+
usageCacheGet: vi.fn(),
|
|
32
|
+
usageCacheGetTimestamp: vi.fn(),
|
|
33
|
+
usageCacheSet: vi.fn(),
|
|
34
|
+
startBackgroundRefresh: vi.fn(),
|
|
35
|
+
fetchAndAggregate: vi.fn(),
|
|
36
|
+
isRateLimitError: vi.fn(),
|
|
37
|
+
getCurrentSessionId: vi.fn(),
|
|
38
|
+
getAllKeys: vi.fn(),
|
|
39
|
+
getCurrentKey: vi.fn(),
|
|
40
|
+
getAccountCredits: vi.fn(),
|
|
41
|
+
computeRollupStatus: vi.fn(),
|
|
42
|
+
sortKeys: vi.fn(),
|
|
43
|
+
syncModels: vi.fn(),
|
|
44
|
+
getSyncState: vi.fn(),
|
|
45
|
+
isSyncEnabled: vi.fn(),
|
|
46
|
+
getSkipReasonsAsync: vi.fn(),
|
|
47
|
+
groupSkipReasons: vi.fn(),
|
|
48
|
+
loadCache: vi.fn(),
|
|
49
|
+
getCacheAgeMs: vi.fn(),
|
|
50
|
+
formatDuration: vi.fn(),
|
|
51
|
+
loadModelOverrides: vi.fn(),
|
|
52
|
+
handleModelOverrideSet: vi.fn(),
|
|
53
|
+
handleModelOverrideClear: vi.fn(),
|
|
54
|
+
handleModelOverrideList: vi.fn(),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
vi.mock('../cache.js', () => ({
|
|
60
|
+
usageCache: {
|
|
61
|
+
get: mocks.usageCacheGet,
|
|
62
|
+
getTimestamp: mocks.usageCacheGetTimestamp,
|
|
63
|
+
set: mocks.usageCacheSet,
|
|
64
|
+
},
|
|
65
|
+
startBackgroundRefresh: mocks.startBackgroundRefresh,
|
|
66
|
+
fetchAndAggregate: mocks.fetchAndAggregate,
|
|
67
|
+
isRateLimitError: mocks.isRateLimitError,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../client.js', () => ({
|
|
71
|
+
AuthError: class AuthError extends Error {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = 'AuthError';
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
vi.mock('../hooks.js', () => ({
|
|
80
|
+
getCurrentSessionId: mocks.getCurrentSessionId,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
vi.mock('../account-client.js', () => ({
|
|
84
|
+
getAllKeys: mocks.getAllKeys,
|
|
85
|
+
getCurrentKey: mocks.getCurrentKey,
|
|
86
|
+
getAccountCredits: mocks.getAccountCredits,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
vi.mock('../account-format.js', () => ({
|
|
90
|
+
computeRollupStatus: mocks.computeRollupStatus,
|
|
91
|
+
sortKeys: mocks.sortKeys,
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
vi.mock('../models/sync.js', () => ({
|
|
95
|
+
syncModels: mocks.syncModels,
|
|
96
|
+
getSyncState: mocks.getSyncState,
|
|
97
|
+
isSyncEnabled: mocks.isSyncEnabled,
|
|
98
|
+
getSkipReasonsAsync: mocks.getSkipReasonsAsync,
|
|
99
|
+
groupSkipReasons: mocks.groupSkipReasons,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
vi.mock('../models/cache.js', () => ({
|
|
103
|
+
loadCache: mocks.loadCache,
|
|
104
|
+
getCacheAgeMs: mocks.getCacheAgeMs,
|
|
105
|
+
formatDuration: mocks.formatDuration,
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
vi.mock('../models/overrides.js', () => ({
|
|
109
|
+
loadModelOverrides: mocks.loadModelOverrides,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
vi.mock('../models/override-commands.js', () => ({
|
|
113
|
+
handleModelOverrideSet: mocks.handleModelOverrideSet,
|
|
114
|
+
handleModelOverrideClear: mocks.handleModelOverrideClear,
|
|
115
|
+
handleModelOverrideList: mocks.handleModelOverrideList,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
vi.mock('../overlay.js', () => ({
|
|
119
|
+
UsageOverlayComponent: MockUsageOverlayComponent,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
import { OPENROUTER_SUBCOMMANDS, registerOpenRouterCommands } from '../commands.js';
|
|
123
|
+
|
|
124
|
+
function createMockPi() {
|
|
125
|
+
const commands = new Map<string, any>();
|
|
126
|
+
return {
|
|
127
|
+
commands,
|
|
128
|
+
pi: {
|
|
129
|
+
registerCommand: vi.fn((name: string, spec: unknown) => {
|
|
130
|
+
commands.set(name, spec);
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createMockContext() {
|
|
137
|
+
return {
|
|
138
|
+
hasUI: true,
|
|
139
|
+
model: { id: 'active-model' },
|
|
140
|
+
sessionManager: {
|
|
141
|
+
getSessionId: vi.fn(() => 'session-123'),
|
|
142
|
+
},
|
|
143
|
+
ui: {
|
|
144
|
+
notify: vi.fn(),
|
|
145
|
+
custom: vi.fn().mockImplementation(async (callback) => {
|
|
146
|
+
// Call the callback to instantiate the component for overlay tests
|
|
147
|
+
try {
|
|
148
|
+
const mockTui = { requestRender: vi.fn() };
|
|
149
|
+
const mockTheme = {
|
|
150
|
+
bold: vi.fn((text: string) => text),
|
|
151
|
+
fg: vi.fn((_style: string, text: string) => text),
|
|
152
|
+
};
|
|
153
|
+
const mockKeybindings = {};
|
|
154
|
+
const mockDone = vi.fn();
|
|
155
|
+
callback(mockTui, mockTheme, mockKeybindings, mockDone);
|
|
156
|
+
} catch {
|
|
157
|
+
// Ignore errors in component instantiation for non-overlay tests
|
|
158
|
+
}
|
|
159
|
+
}),
|
|
160
|
+
setStatus: vi.fn(),
|
|
161
|
+
theme: {
|
|
162
|
+
fg: vi.fn((_style: string, text: string) => text),
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
} as any;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const keyInfo = {
|
|
169
|
+
name: 'Primary',
|
|
170
|
+
label: 'sk-or-v1-123',
|
|
171
|
+
status: 'healthy',
|
|
172
|
+
used: 10,
|
|
173
|
+
remaining: 90,
|
|
174
|
+
limit: 100,
|
|
175
|
+
resetCadence: 'monthly',
|
|
176
|
+
byok: 'incl',
|
|
177
|
+
hash: 'hash-1',
|
|
178
|
+
disabled: false,
|
|
179
|
+
workspaceName: 'Workspace',
|
|
180
|
+
spend: 10,
|
|
181
|
+
} as const;
|
|
182
|
+
|
|
183
|
+
describe('registerOpenRouterCommands', () => {
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
vi.resetAllMocks();
|
|
186
|
+
overlayConstructorCalls.length = 0;
|
|
187
|
+
|
|
188
|
+
mocks.getCurrentSessionId.mockReturnValue('pi:session-123');
|
|
189
|
+
mocks.usageCacheGet.mockReturnValue(null);
|
|
190
|
+
mocks.usageCacheGetTimestamp.mockReturnValue(null);
|
|
191
|
+
mocks.fetchAndAggregate.mockResolvedValue({});
|
|
192
|
+
mocks.isRateLimitError.mockImplementation((error: unknown) => {
|
|
193
|
+
const message = String(error).toLowerCase();
|
|
194
|
+
return (
|
|
195
|
+
message.includes('429') || message.includes('rate limit') || message.includes('rate-limit')
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
mocks.getAllKeys.mockResolvedValue([keyInfo]);
|
|
199
|
+
mocks.getCurrentKey.mockResolvedValue(keyInfo);
|
|
200
|
+
mocks.getAccountCredits.mockResolvedValue(25);
|
|
201
|
+
mocks.computeRollupStatus.mockReturnValue({ status: 'healthy', message: 'healthy' });
|
|
202
|
+
mocks.sortKeys.mockImplementation((keys) => keys);
|
|
203
|
+
mocks.syncModels.mockResolvedValue({ success: true, registeredCount: 3, skippedCount: 0 });
|
|
204
|
+
mocks.getSyncState.mockReturnValue(null);
|
|
205
|
+
mocks.isSyncEnabled.mockReturnValue(true);
|
|
206
|
+
mocks.getSkipReasonsAsync.mockResolvedValue([]);
|
|
207
|
+
mocks.groupSkipReasons.mockReturnValue({});
|
|
208
|
+
mocks.loadCache.mockResolvedValue(null);
|
|
209
|
+
mocks.getCacheAgeMs.mockReturnValue(60000);
|
|
210
|
+
mocks.formatDuration.mockReturnValue('1 minute');
|
|
211
|
+
mocks.loadModelOverrides.mockResolvedValue({});
|
|
212
|
+
mocks.handleModelOverrideSet.mockResolvedValue({ success: true, message: 'override set' });
|
|
213
|
+
mocks.handleModelOverrideClear.mockResolvedValue({
|
|
214
|
+
success: true,
|
|
215
|
+
message: 'override cleared',
|
|
216
|
+
});
|
|
217
|
+
mocks.handleModelOverrideList.mockResolvedValue('override list');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('registers the expected command names and descriptions', () => {
|
|
221
|
+
const { commands, pi } = createMockPi();
|
|
222
|
+
|
|
223
|
+
registerOpenRouterCommands(pi as any);
|
|
224
|
+
|
|
225
|
+
expect([...commands.keys()]).toEqual([
|
|
226
|
+
'openrouter-usage',
|
|
227
|
+
'openrouter-session',
|
|
228
|
+
'openrouter-account',
|
|
229
|
+
'openrouter',
|
|
230
|
+
]);
|
|
231
|
+
expect(commands.get('openrouter-usage')?.description).toBe(
|
|
232
|
+
'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
|
|
233
|
+
);
|
|
234
|
+
expect(commands.get('openrouter-session')?.description).toBe(
|
|
235
|
+
'Show the current OpenRouter session ID for request grouping',
|
|
236
|
+
);
|
|
237
|
+
expect(commands.get('openrouter-account')?.description).toBe(
|
|
238
|
+
'Show OpenRouter account and key health',
|
|
239
|
+
);
|
|
240
|
+
expect(commands.get('openrouter')?.description).toBe(
|
|
241
|
+
'OpenRouter commands: usage, account, session, models-sync, models-status',
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('keeps /openrouter subcommand completions unchanged', () => {
|
|
246
|
+
const { commands, pi } = createMockPi();
|
|
247
|
+
|
|
248
|
+
registerOpenRouterCommands(pi as any);
|
|
249
|
+
const command = commands.get('openrouter');
|
|
250
|
+
|
|
251
|
+
expect(command.getArgumentCompletions('model-')).toEqual([
|
|
252
|
+
{ value: 'model-override-set', label: 'model-override-set' },
|
|
253
|
+
{ value: 'model-override-clear', label: 'model-override-clear' },
|
|
254
|
+
{ value: 'model-override-list', label: 'model-override-list' },
|
|
255
|
+
]);
|
|
256
|
+
expect(command.getArgumentCompletions('zzz')).toBeNull();
|
|
257
|
+
expect(OPENROUTER_SUBCOMMANDS).toEqual([
|
|
258
|
+
'usage',
|
|
259
|
+
'account',
|
|
260
|
+
'session',
|
|
261
|
+
'models-sync',
|
|
262
|
+
'models-status',
|
|
263
|
+
'model-override-set',
|
|
264
|
+
'model-override-clear',
|
|
265
|
+
'model-override-list',
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('routes /openrouter usage through background refresh and overlay rendering', async () => {
|
|
270
|
+
const { commands, pi } = createMockPi();
|
|
271
|
+
const ctx = createMockContext();
|
|
272
|
+
|
|
273
|
+
mocks.usageCacheGet.mockReturnValue({ total: {} });
|
|
274
|
+
mocks.usageCacheGetTimestamp.mockReturnValue(Date.now());
|
|
275
|
+
|
|
276
|
+
registerOpenRouterCommands(pi as any);
|
|
277
|
+
await commands.get('openrouter').handler('usage', ctx);
|
|
278
|
+
|
|
279
|
+
expect(mocks.startBackgroundRefresh).toHaveBeenCalledTimes(1);
|
|
280
|
+
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('routes /openrouter account to the account overlay flow', async () => {
|
|
284
|
+
const { commands, pi } = createMockPi();
|
|
285
|
+
const ctx = createMockContext();
|
|
286
|
+
|
|
287
|
+
registerOpenRouterCommands(pi as any);
|
|
288
|
+
await commands.get('openrouter').handler('account', ctx);
|
|
289
|
+
|
|
290
|
+
expect(mocks.getAllKeys).toHaveBeenCalledTimes(1);
|
|
291
|
+
expect(mocks.getAccountCredits).toHaveBeenCalledTimes(1);
|
|
292
|
+
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('routes /openrouter session to the current session notifier', async () => {
|
|
296
|
+
const { commands, pi } = createMockPi();
|
|
297
|
+
const ctx = createMockContext();
|
|
298
|
+
|
|
299
|
+
registerOpenRouterCommands(pi as any);
|
|
300
|
+
await commands.get('openrouter').handler('session', ctx);
|
|
301
|
+
|
|
302
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith('OpenRouter session_id\npi:session-123', 'info');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('keeps models-sync disabled messaging unchanged', async () => {
|
|
306
|
+
const { commands, pi } = createMockPi();
|
|
307
|
+
const ctx = createMockContext();
|
|
308
|
+
|
|
309
|
+
mocks.isSyncEnabled.mockReturnValue(false);
|
|
310
|
+
|
|
311
|
+
registerOpenRouterCommands(pi as any);
|
|
312
|
+
await commands.get('openrouter').handler('models-sync', ctx);
|
|
313
|
+
|
|
314
|
+
expect(mocks.syncModels).not.toHaveBeenCalled();
|
|
315
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
316
|
+
'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
|
|
317
|
+
'error',
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('keeps models-sync success notifications unchanged', async () => {
|
|
322
|
+
const { commands, pi } = createMockPi();
|
|
323
|
+
const ctx = createMockContext();
|
|
324
|
+
|
|
325
|
+
mocks.syncModels.mockResolvedValue({
|
|
326
|
+
success: true,
|
|
327
|
+
registeredCount: 9,
|
|
328
|
+
skippedCount: 2,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
registerOpenRouterCommands(pi as any);
|
|
332
|
+
await commands.get('openrouter').handler('models-sync', ctx);
|
|
333
|
+
|
|
334
|
+
expect(mocks.syncModels).toHaveBeenCalledWith(ctx);
|
|
335
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
336
|
+
'OpenRouter models synced\n9 registered · 2 skipped · cache updated',
|
|
337
|
+
'info',
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('shows grouped skipped-details hints once per reason', async () => {
|
|
342
|
+
const { commands, pi } = createMockPi();
|
|
343
|
+
const ctx = createMockContext();
|
|
344
|
+
|
|
345
|
+
mocks.getSyncState.mockReturnValue({ success: true, registeredCount: 7 });
|
|
346
|
+
mocks.getSkipReasonsAsync.mockResolvedValue([
|
|
347
|
+
{
|
|
348
|
+
id: 'provider/a',
|
|
349
|
+
reason: 'missing context window',
|
|
350
|
+
hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
|
|
351
|
+
},
|
|
352
|
+
{ id: 'provider/b', reason: 'missing context window' },
|
|
353
|
+
]);
|
|
354
|
+
mocks.groupSkipReasons.mockReturnValue({ 'missing context window': 2 });
|
|
355
|
+
mocks.loadCache.mockResolvedValue({ models: [], timestamp: Date.now() - 60000 });
|
|
356
|
+
mocks.getCacheAgeMs.mockReturnValue(60000);
|
|
357
|
+
mocks.formatDuration.mockReturnValue('1 minute');
|
|
358
|
+
|
|
359
|
+
registerOpenRouterCommands(pi as any);
|
|
360
|
+
await commands.get('openrouter').handler('models-status --skipped', ctx);
|
|
361
|
+
|
|
362
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
363
|
+
"OpenRouter models healthy\n7 registered · 2 skipped · cache age: 1 minute\n\nOpenRouter skipped models: 2\n\n2 missing context window\n suggestion: Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.\n- provider/a\n- provider/b\n",
|
|
364
|
+
'info',
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('keeps model-override-set routing and active-model refresh notice unchanged', async () => {
|
|
369
|
+
const { commands, pi } = createMockPi();
|
|
370
|
+
const ctx = createMockContext();
|
|
371
|
+
|
|
372
|
+
mocks.handleModelOverrideSet.mockResolvedValue({
|
|
373
|
+
success: true,
|
|
374
|
+
message: 'override set',
|
|
375
|
+
modelId: 'active-model',
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
registerOpenRouterCommands(pi as any);
|
|
379
|
+
await commands
|
|
380
|
+
.get('openrouter')
|
|
381
|
+
.handler('model-override-set anthropic/claude-sonnet-4 maxTokens=2048', ctx);
|
|
382
|
+
|
|
383
|
+
expect(mocks.loadModelOverrides).toHaveBeenCalledTimes(1);
|
|
384
|
+
expect(mocks.handleModelOverrideSet).toHaveBeenCalledWith(
|
|
385
|
+
'anthropic/claude-sonnet-4 maxTokens=2048',
|
|
386
|
+
{},
|
|
387
|
+
);
|
|
388
|
+
expect(ctx.ui.notify).toHaveBeenNthCalledWith(1, 'override set', 'info');
|
|
389
|
+
expect(ctx.ui.notify).toHaveBeenNthCalledWith(
|
|
390
|
+
2,
|
|
391
|
+
'Model configuration updated. Run /openrouter models-sync to apply changes to the current conversation.',
|
|
392
|
+
'info',
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('keeps model-override-clear failure routing unchanged', async () => {
|
|
397
|
+
const { commands, pi } = createMockPi();
|
|
398
|
+
const ctx = createMockContext();
|
|
399
|
+
|
|
400
|
+
mocks.handleModelOverrideClear.mockResolvedValue({
|
|
401
|
+
success: false,
|
|
402
|
+
message: 'override clear failed',
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
registerOpenRouterCommands(pi as any);
|
|
406
|
+
await commands.get('openrouter').handler('model-override-clear anthropic/claude-sonnet-4', ctx);
|
|
407
|
+
|
|
408
|
+
expect(mocks.handleModelOverrideClear).toHaveBeenCalledWith('anthropic/claude-sonnet-4', {});
|
|
409
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith('override clear failed', 'error');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('keeps model-override-list routing unchanged', async () => {
|
|
413
|
+
const { commands, pi } = createMockPi();
|
|
414
|
+
const ctx = createMockContext();
|
|
415
|
+
|
|
416
|
+
registerOpenRouterCommands(pi as any);
|
|
417
|
+
await commands.get('openrouter').handler('model-override-list anthropic/', ctx);
|
|
418
|
+
|
|
419
|
+
expect(mocks.handleModelOverrideList).toHaveBeenCalledWith('anthropic/');
|
|
420
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith('override list', 'info');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('keeps unknown-subcommand messaging unchanged', async () => {
|
|
424
|
+
const { commands, pi } = createMockPi();
|
|
425
|
+
const ctx = createMockContext();
|
|
426
|
+
|
|
427
|
+
registerOpenRouterCommands(pi as any);
|
|
428
|
+
await commands.get('openrouter').handler('wat', ctx);
|
|
429
|
+
|
|
430
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
431
|
+
`OpenRouter subcommands\nAvailable subcommands: ${OPENROUTER_SUBCOMMANDS.join(', ')}`,
|
|
432
|
+
'error',
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe('models-sync failure paths', () => {
|
|
437
|
+
it('shows cache-backed failure with warning level and cache metadata', async () => {
|
|
438
|
+
const { commands, pi } = createMockPi();
|
|
439
|
+
const ctx = createMockContext();
|
|
440
|
+
|
|
441
|
+
mocks.syncModels.mockResolvedValue({
|
|
442
|
+
success: false,
|
|
443
|
+
source: 'cache',
|
|
444
|
+
registeredCount: 5,
|
|
445
|
+
cacheAgeMs: 300000,
|
|
446
|
+
error: 'API timeout',
|
|
447
|
+
});
|
|
448
|
+
mocks.formatDuration.mockReturnValue('5 minutes');
|
|
449
|
+
|
|
450
|
+
registerOpenRouterCommands(pi as any);
|
|
451
|
+
await commands.get('openrouter').handler('models-sync', ctx);
|
|
452
|
+
|
|
453
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
454
|
+
'OpenRouter models sync failed\n5 registered from cache\nCache age: 5 minutes\nError: API timeout',
|
|
455
|
+
'warning',
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('shows hard failure with error level and zero registered', async () => {
|
|
460
|
+
const { commands, pi } = createMockPi();
|
|
461
|
+
const ctx = createMockContext();
|
|
462
|
+
|
|
463
|
+
mocks.syncModels.mockResolvedValue({
|
|
464
|
+
success: false,
|
|
465
|
+
source: 'none',
|
|
466
|
+
error: 'Network unreachable',
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
registerOpenRouterCommands(pi as any);
|
|
470
|
+
await commands.get('openrouter').handler('models-sync', ctx);
|
|
471
|
+
|
|
472
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
473
|
+
'OpenRouter models unavailable\n0 registered\nError: Network unreachable',
|
|
474
|
+
'error',
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('models-status branch coverage', () => {
|
|
480
|
+
it('shows not-synced error when no state and no cache', async () => {
|
|
481
|
+
const { commands, pi } = createMockPi();
|
|
482
|
+
const ctx = createMockContext();
|
|
483
|
+
|
|
484
|
+
mocks.getSyncState.mockReturnValue(null);
|
|
485
|
+
mocks.loadCache.mockResolvedValue(null);
|
|
486
|
+
|
|
487
|
+
registerOpenRouterCommands(pi as any);
|
|
488
|
+
await commands.get('openrouter').handler('models-status', ctx);
|
|
489
|
+
|
|
490
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith('OpenRouter models: not synced', 'error');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('shows cached-only info with sync hint when no state but cache exists', async () => {
|
|
494
|
+
const { commands, pi } = createMockPi();
|
|
495
|
+
const ctx = createMockContext();
|
|
496
|
+
|
|
497
|
+
mocks.getSyncState.mockReturnValue(null);
|
|
498
|
+
mocks.loadCache.mockResolvedValue({ models: [{}, {}, {}], timestamp: Date.now() - 120000 });
|
|
499
|
+
mocks.getCacheAgeMs.mockReturnValue(120000);
|
|
500
|
+
mocks.formatDuration.mockReturnValue('2 minutes');
|
|
501
|
+
|
|
502
|
+
registerOpenRouterCommands(pi as any);
|
|
503
|
+
await commands.get('openrouter').handler('models-status', ctx);
|
|
504
|
+
|
|
505
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
506
|
+
"OpenRouter models cached\n3 models in cache · age: 2 minutes\nRun '/openrouter models-sync' to register models",
|
|
507
|
+
'info',
|
|
508
|
+
);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('shows cache-backed failure warning with skip details when --skipped flag present', async () => {
|
|
512
|
+
const { commands, pi } = createMockPi();
|
|
513
|
+
const ctx = createMockContext();
|
|
514
|
+
|
|
515
|
+
mocks.getSyncState.mockReturnValue({
|
|
516
|
+
success: false,
|
|
517
|
+
source: 'cache',
|
|
518
|
+
registeredCount: 8,
|
|
519
|
+
error: 'Auth expired',
|
|
520
|
+
});
|
|
521
|
+
mocks.getSkipReasonsAsync.mockResolvedValue([
|
|
522
|
+
{ id: 'test/model', reason: 'missing pricing' },
|
|
523
|
+
]);
|
|
524
|
+
mocks.groupSkipReasons.mockReturnValue({ 'missing pricing': 1 });
|
|
525
|
+
mocks.loadCache.mockResolvedValue({ models: [], timestamp: Date.now() - 180000 });
|
|
526
|
+
mocks.getCacheAgeMs.mockReturnValue(180000);
|
|
527
|
+
mocks.formatDuration.mockReturnValue('3 minutes');
|
|
528
|
+
|
|
529
|
+
registerOpenRouterCommands(pi as any);
|
|
530
|
+
await commands.get('openrouter').handler('models-status --skipped', ctx);
|
|
531
|
+
|
|
532
|
+
const notifyCall = ctx.ui.notify.mock.calls[0];
|
|
533
|
+
expect(notifyCall[1]).toBe('warning');
|
|
534
|
+
expect(notifyCall[0]).toContain('OpenRouter models cached');
|
|
535
|
+
expect(notifyCall[0]).toContain('8 registered · 1 skipped');
|
|
536
|
+
expect(notifyCall[0]).toContain('Cache age: 3 minutes');
|
|
537
|
+
expect(notifyCall[0]).toContain('Error: Auth expired');
|
|
538
|
+
expect(notifyCall[0]).toContain('missing pricing');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('shows broken error when state exists but not from cache and not success', async () => {
|
|
542
|
+
const { commands, pi } = createMockPi();
|
|
543
|
+
const ctx = createMockContext();
|
|
544
|
+
|
|
545
|
+
mocks.getSyncState.mockReturnValue({
|
|
546
|
+
success: false,
|
|
547
|
+
source: 'api',
|
|
548
|
+
error: 'Invalid API key',
|
|
549
|
+
});
|
|
550
|
+
mocks.loadCache.mockResolvedValue(null);
|
|
551
|
+
|
|
552
|
+
registerOpenRouterCommands(pi as any);
|
|
553
|
+
await commands.get('openrouter').handler('models-status', ctx);
|
|
554
|
+
|
|
555
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
556
|
+
'OpenRouter models broken\n0 registered\nError: Invalid API key',
|
|
557
|
+
'error',
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe('showUsageOverlay stale and error paths', () => {
|
|
563
|
+
beforeEach(() => {
|
|
564
|
+
overlayConstructorCalls.length = 0;
|
|
565
|
+
vi.useFakeTimers();
|
|
566
|
+
vi.setSystemTime(new Date('2026-05-25T12:00:00Z'));
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
afterEach(() => {
|
|
570
|
+
vi.useRealTimers();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('shows stale summary when fetchAndAggregate returns null', async () => {
|
|
574
|
+
const { commands, pi } = createMockPi();
|
|
575
|
+
const ctx = createMockContext();
|
|
576
|
+
|
|
577
|
+
const staleTimestamp = Date.now() - 5 * 60 * 1000; // 5 minutes ago
|
|
578
|
+
const staleSummary = { total: { usage: 100 } };
|
|
579
|
+
|
|
580
|
+
mocks.usageCacheGet.mockImplementation((_key, opts) =>
|
|
581
|
+
opts?.allowStale ? staleSummary : null,
|
|
582
|
+
);
|
|
583
|
+
mocks.usageCacheGetTimestamp.mockImplementation((_key, opts) =>
|
|
584
|
+
opts?.allowStale ? staleTimestamp : null,
|
|
585
|
+
);
|
|
586
|
+
mocks.fetchAndAggregate.mockResolvedValue(null);
|
|
587
|
+
|
|
588
|
+
registerOpenRouterCommands(pi as any);
|
|
589
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
590
|
+
|
|
591
|
+
expect(overlayConstructorCalls).toHaveLength(1);
|
|
592
|
+
expect(overlayConstructorCalls[0]).toEqual({
|
|
593
|
+
summary: staleSummary,
|
|
594
|
+
error:
|
|
595
|
+
'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.\nShowing last successful usage data.',
|
|
596
|
+
cachedMinutesAgo: 5,
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('shows stale summary when fetchAndAggregate throws', async () => {
|
|
601
|
+
const { commands, pi } = createMockPi();
|
|
602
|
+
const ctx = createMockContext();
|
|
603
|
+
|
|
604
|
+
const staleTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes ago
|
|
605
|
+
const staleSummary = { total: { usage: 200 } };
|
|
606
|
+
|
|
607
|
+
mocks.usageCacheGet.mockImplementation((_key, opts) =>
|
|
608
|
+
opts?.allowStale ? staleSummary : null,
|
|
609
|
+
);
|
|
610
|
+
mocks.usageCacheGetTimestamp.mockImplementation((_key, opts) =>
|
|
611
|
+
opts?.allowStale ? staleTimestamp : null,
|
|
612
|
+
);
|
|
613
|
+
mocks.fetchAndAggregate.mockRejectedValue(new Error('Connection timeout'));
|
|
614
|
+
|
|
615
|
+
registerOpenRouterCommands(pi as any);
|
|
616
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
617
|
+
|
|
618
|
+
expect(overlayConstructorCalls).toHaveLength(1);
|
|
619
|
+
expect(overlayConstructorCalls[0]).toEqual({
|
|
620
|
+
summary: staleSummary,
|
|
621
|
+
error: 'API Error: Connection timeout\nShowing last successful usage data.',
|
|
622
|
+
cachedMinutesAgo: 10,
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('shows error-only overlay when no stale data available', async () => {
|
|
627
|
+
const { commands, pi } = createMockPi();
|
|
628
|
+
const ctx = createMockContext();
|
|
629
|
+
|
|
630
|
+
mocks.usageCacheGet.mockReturnValue(null);
|
|
631
|
+
mocks.usageCacheGetTimestamp.mockReturnValue(null);
|
|
632
|
+
mocks.fetchAndAggregate.mockResolvedValue(null);
|
|
633
|
+
|
|
634
|
+
registerOpenRouterCommands(pi as any);
|
|
635
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
636
|
+
|
|
637
|
+
expect(overlayConstructorCalls).toHaveLength(1);
|
|
638
|
+
expect(overlayConstructorCalls[0]).toEqual({
|
|
639
|
+
summary: null,
|
|
640
|
+
error:
|
|
641
|
+
'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.',
|
|
642
|
+
cachedMinutesAgo: null,
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('shows error-only overlay with null cachedMinutesAgo when fetchAndAggregate throws and no stale data', async () => {
|
|
647
|
+
const { commands, pi } = createMockPi();
|
|
648
|
+
const ctx = createMockContext();
|
|
649
|
+
|
|
650
|
+
mocks.usageCacheGet.mockReturnValue(null);
|
|
651
|
+
mocks.usageCacheGetTimestamp.mockReturnValue(null);
|
|
652
|
+
mocks.fetchAndAggregate.mockRejectedValue(new Error('Network error'));
|
|
653
|
+
|
|
654
|
+
registerOpenRouterCommands(pi as any);
|
|
655
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
656
|
+
|
|
657
|
+
expect(overlayConstructorCalls).toHaveLength(1);
|
|
658
|
+
expect(overlayConstructorCalls[0]).toEqual({
|
|
659
|
+
summary: null,
|
|
660
|
+
error: 'API Error: Network error',
|
|
661
|
+
cachedMinutesAgo: null,
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
describe('startUsageBackgroundRefresh notify gating', () => {
|
|
667
|
+
it('does not notify when ctx.hasUI is false', async () => {
|
|
668
|
+
const { commands, pi } = createMockPi();
|
|
669
|
+
const ctx = createMockContext();
|
|
670
|
+
ctx.hasUI = false;
|
|
671
|
+
|
|
672
|
+
registerOpenRouterCommands(pi as any);
|
|
673
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
674
|
+
|
|
675
|
+
const onFailure = mocks.startBackgroundRefresh.mock.calls[0]?.[0]?.onFailure;
|
|
676
|
+
expect(onFailure).toBeDefined();
|
|
677
|
+
onFailure!({
|
|
678
|
+
status: 'failed',
|
|
679
|
+
consecutiveFailures: 5,
|
|
680
|
+
lastError: 'Persistent failure',
|
|
681
|
+
nextDelayMs: 60000,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('does not notify when lastError is missing', async () => {
|
|
688
|
+
const { commands, pi } = createMockPi();
|
|
689
|
+
const ctx = createMockContext();
|
|
690
|
+
|
|
691
|
+
registerOpenRouterCommands(pi as any);
|
|
692
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
693
|
+
|
|
694
|
+
const onFailure = mocks.startBackgroundRefresh.mock.calls[0]?.[0]?.onFailure;
|
|
695
|
+
expect(onFailure).toBeDefined();
|
|
696
|
+
onFailure!({
|
|
697
|
+
status: 'failed',
|
|
698
|
+
consecutiveFailures: 5,
|
|
699
|
+
lastError: null,
|
|
700
|
+
nextDelayMs: 60000,
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('does not notify for transient non-rate-limit failures', async () => {
|
|
707
|
+
const { commands, pi } = createMockPi();
|
|
708
|
+
const ctx = createMockContext();
|
|
709
|
+
|
|
710
|
+
registerOpenRouterCommands(pi as any);
|
|
711
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
712
|
+
|
|
713
|
+
const onFailure = mocks.startBackgroundRefresh.mock.calls[0]?.[0]?.onFailure;
|
|
714
|
+
expect(onFailure).toBeDefined();
|
|
715
|
+
onFailure!({
|
|
716
|
+
status: 'failed',
|
|
717
|
+
consecutiveFailures: 2,
|
|
718
|
+
lastError: 'Temporary glitch',
|
|
719
|
+
nextDelayMs: 10000,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('notifies for persistent failures with warning level', async () => {
|
|
726
|
+
const { commands, pi } = createMockPi();
|
|
727
|
+
const ctx = createMockContext();
|
|
728
|
+
|
|
729
|
+
registerOpenRouterCommands(pi as any);
|
|
730
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
731
|
+
|
|
732
|
+
const onFailure = mocks.startBackgroundRefresh.mock.calls[0]?.[0]?.onFailure;
|
|
733
|
+
expect(onFailure).toBeDefined();
|
|
734
|
+
onFailure!({
|
|
735
|
+
status: 'failed',
|
|
736
|
+
consecutiveFailures: 4,
|
|
737
|
+
lastError: 'Persistent auth error',
|
|
738
|
+
nextDelayMs: 120000,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
742
|
+
'OpenRouter usage refresh failed\nPersistent auth error',
|
|
743
|
+
'warning',
|
|
744
|
+
);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('notifies for rate-limit failures even before persistence threshold', async () => {
|
|
748
|
+
const { commands, pi } = createMockPi();
|
|
749
|
+
const ctx = createMockContext();
|
|
750
|
+
|
|
751
|
+
registerOpenRouterCommands(pi as any);
|
|
752
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
753
|
+
|
|
754
|
+
const onFailure = mocks.startBackgroundRefresh.mock.calls[0]?.[0]?.onFailure;
|
|
755
|
+
expect(onFailure).toBeDefined();
|
|
756
|
+
onFailure!({
|
|
757
|
+
status: 'failed',
|
|
758
|
+
consecutiveFailures: 1,
|
|
759
|
+
lastError: 'Rate limit exceeded',
|
|
760
|
+
nextDelayMs: 60000,
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
764
|
+
'OpenRouter usage refresh failed\nRate limit exceeded',
|
|
765
|
+
'warning',
|
|
766
|
+
);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it.each(['HTTP 429: Too Many Requests', 'rate-limit exceeded for this key'])(
|
|
770
|
+
'notifies for rate-limit error: %s',
|
|
771
|
+
async (lastError) => {
|
|
772
|
+
const { commands, pi } = createMockPi();
|
|
773
|
+
const ctx = createMockContext();
|
|
774
|
+
|
|
775
|
+
registerOpenRouterCommands(pi as any);
|
|
776
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
777
|
+
|
|
778
|
+
const onFailure = mocks.startBackgroundRefresh.mock.calls[0]?.[0]?.onFailure;
|
|
779
|
+
expect(onFailure).toBeDefined();
|
|
780
|
+
onFailure!({
|
|
781
|
+
status: 'failed',
|
|
782
|
+
consecutiveFailures: 1,
|
|
783
|
+
lastError,
|
|
784
|
+
nextDelayMs: 60000,
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
788
|
+
`OpenRouter usage refresh failed\n${lastError}`,
|
|
789
|
+
'warning',
|
|
790
|
+
);
|
|
791
|
+
},
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
it('includes stale suffix when status is stale', async () => {
|
|
795
|
+
const { commands, pi } = createMockPi();
|
|
796
|
+
const ctx = createMockContext();
|
|
797
|
+
|
|
798
|
+
registerOpenRouterCommands(pi as any);
|
|
799
|
+
await commands.get('openrouter-usage').handler('', ctx);
|
|
800
|
+
|
|
801
|
+
const onFailure = mocks.startBackgroundRefresh.mock.calls[0]?.[0]?.onFailure;
|
|
802
|
+
expect(onFailure).toBeDefined();
|
|
803
|
+
onFailure!({
|
|
804
|
+
status: 'stale',
|
|
805
|
+
consecutiveFailures: 5,
|
|
806
|
+
lastError: 'API down',
|
|
807
|
+
nextDelayMs: 240000,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
811
|
+
'OpenRouter usage refresh stale\nAPI down\nShowing last successful usage data.',
|
|
812
|
+
'warning',
|
|
813
|
+
);
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
});
|