@robhowley/pi-openrouter 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +39 -4
  2. package/extensions/openrouter/__tests__/cache.test.ts +769 -0
  3. package/extensions/openrouter/__tests__/client.test.ts +333 -15
  4. package/extensions/openrouter/__tests__/commands.test.ts +816 -0
  5. package/extensions/openrouter/__tests__/fixtures.ts +140 -1
  6. package/extensions/openrouter/__tests__/format.test.ts +19 -0
  7. package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
  8. package/extensions/openrouter/__tests__/index.test.ts +112 -363
  9. package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
  10. package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
  11. package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
  12. package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
  13. package/extensions/openrouter/__tests__/session.test.ts +44 -43
  14. package/extensions/openrouter/account-client.ts +11 -61
  15. package/extensions/openrouter/cache.ts +203 -91
  16. package/extensions/openrouter/client.ts +49 -3
  17. package/extensions/openrouter/commands.ts +555 -0
  18. package/extensions/openrouter/format.ts +7 -4
  19. package/extensions/openrouter/hooks.ts +229 -0
  20. package/extensions/openrouter/index.ts +13 -990
  21. package/extensions/openrouter/local-usage.ts +145 -22
  22. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  23. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  24. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  25. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  26. package/extensions/openrouter/models/cache.ts +27 -2
  27. package/extensions/openrouter/models/mapper.ts +35 -69
  28. package/extensions/openrouter/models/override-commands.ts +434 -0
  29. package/extensions/openrouter/models/skip-hints.ts +19 -0
  30. package/extensions/openrouter/models/sync.ts +22 -10
  31. package/extensions/openrouter/models/types.ts +2 -1
  32. package/extensions/openrouter/normalizers.ts +128 -0
  33. package/extensions/openrouter/overlay.ts +19 -8
  34. package/extensions/openrouter/session-state.ts +110 -0
  35. package/extensions/openrouter/session.ts +16 -0
  36. package/extensions/openrouter/types.ts +28 -9
  37. package/package.json +1 -1
@@ -1,414 +1,163 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import type { ModelOverridesFile } from '../models/types.js';
3
-
4
- const { loadModelOverrides, saveModelOverrides } = vi.hoisted(() => ({
5
- loadModelOverrides: vi.fn<() => Promise<ModelOverridesFile>>(),
6
- saveModelOverrides: vi.fn(),
7
- }));
8
-
9
- vi.mock('../models/overrides.js', async (importOriginal) => {
10
- const actual = await importOriginal<typeof import('../models/overrides.js')>();
11
- return {
12
- ...actual,
13
- loadModelOverrides,
14
- saveModelOverrides,
15
- };
16
- });
2
+ import { createSessionCtx, createOpenRouterRequest, THROW_SESSION_ID } from './fixtures.js';
17
3
 
18
- import {
19
- applyNestedValue,
20
- handleModelOverrideClear,
21
- handleModelOverrideList,
22
- handleModelOverrideSet,
23
- parseScopedAssignment,
24
- } from '../index.js';
25
-
26
- const emptyOverrides = (): ModelOverridesFile => ({ version: 1, overrides: {} });
27
-
28
- describe('parseScopedAssignment', () => {
29
- it('parses thinking shorthand aliases', () => {
30
- expect(parseScopedAssignment('thinking.high=high')).toEqual({
31
- fullPath: 'thinkingLevelMap.high',
32
- value: 'high',
33
- });
4
+ describe('addSessionIdToOpenRouterRequest', () => {
5
+ beforeEach(() => {
6
+ vi.resetModules();
34
7
  });
35
8
 
36
- it('parses exact thinkingLevelMap field names', () => {
37
- expect(parseScopedAssignment('thinkingLevelMap.xhigh=max')).toEqual({
38
- fullPath: 'thinkingLevelMap.xhigh',
39
- value: 'max',
40
- });
41
- });
9
+ it('should add session_id to OpenRouter requests', async () => {
10
+ const { addSessionIdToOpenRouterRequest } = await import('../index.js');
42
11
 
43
- it('parses null string values for thinking levels', () => {
44
- expect(parseScopedAssignment('thinking.off=null')).toEqual({
45
- fullPath: 'thinkingLevelMap.off',
46
- value: null,
47
- });
48
- });
12
+ const mockCtx = createSessionCtx('stable-session-123');
13
+ const mockEvent = createOpenRouterRequest();
49
14
 
50
- it('parses numeric and boolean top-level fields', () => {
51
- expect(parseScopedAssignment('contextWindow=64000')).toEqual({
52
- fullPath: 'contextWindow',
53
- value: 64000,
54
- });
55
- expect(parseScopedAssignment('maxTokens=8192')).toEqual({
56
- fullPath: 'maxTokens',
57
- value: 8192,
58
- });
59
- expect(parseScopedAssignment('reasoning=false')).toEqual({
60
- fullPath: 'reasoning',
61
- value: false,
62
- });
63
- });
15
+ const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
64
16
 
65
- it('rejects malformed or unsupported assignments', () => {
66
- expect(parseScopedAssignment('thinking.high')).toBeNull();
67
- expect(parseScopedAssignment('unknown.field=value')).toBeNull();
68
- expect(parseScopedAssignment('contextWindow=large')).toBeNull();
69
- expect(parseScopedAssignment('reasoning=yes')).toBeNull();
17
+ expect(result).toBeDefined();
18
+ expect(result?.['session_id']).toBe('pi:stable-session-123');
19
+ expect(result?.['model']).toBe('openrouter/anthropic/claude-sonnet-4');
70
20
  });
71
- });
72
21
 
73
- describe('applyNestedValue', () => {
74
- it('applies nested and top-level values', () => {
75
- const target: Record<string, unknown> = {};
22
+ it('should return same session_id for multiple OpenRouter requests in same session', async () => {
23
+ const { addSessionIdToOpenRouterRequest } = await import('../index.js');
76
24
 
77
- applyNestedValue(target, 'thinkingLevelMap.high', 'high');
78
- applyNestedValue(target, 'contextWindow', 64000);
79
-
80
- expect(target).toEqual({
81
- thinkingLevelMap: { high: 'high' },
82
- contextWindow: 64000,
25
+ const mockCtx = createSessionCtx('stable-session-123');
26
+ const mockEvent1 = createOpenRouterRequest({
27
+ payload: { model: 'openrouter/model-1', messages: [] },
83
28
  });
84
- });
85
-
86
- it('replaces non-object intermediate values with objects', () => {
87
- const target: Record<string, unknown> = { thinkingLevelMap: 'bad' };
88
-
89
- applyNestedValue(target, 'thinkingLevelMap.high', 'high');
90
-
91
- expect(target).toEqual({ thinkingLevelMap: { high: 'high' } });
92
- });
93
- });
94
-
95
- describe('handleModelOverrideSet', () => {
96
- beforeEach(() => {
97
- loadModelOverrides.mockReset();
98
- saveModelOverrides.mockReset();
99
- loadModelOverrides.mockResolvedValue(emptyOverrides());
100
- saveModelOverrides.mockResolvedValue(undefined);
101
- });
102
-
103
- it('returns usage when no model id is provided', async () => {
104
- const result = await handleModelOverrideSet('', emptyOverrides());
105
-
106
- expect(result.success).toBe(false);
107
- expect(result.message).toContain('Usage: /openrouter model-override-set');
108
- expect(saveModelOverrides).not.toHaveBeenCalled();
109
- });
110
-
111
- it('rejects model IDs without provider/model format', async () => {
112
- const result = await handleModelOverrideSet(
113
- 'not-a-model contextWindow=64000',
114
- emptyOverrides(),
115
- );
116
-
117
- expect(result).toEqual({
118
- success: false,
119
- message:
120
- 'Invalid model ID format: "not-a-model"\nExpected format: provider/model (e.g., "deepseek/deepseek-v4-pro")',
29
+ const mockEvent2 = createOpenRouterRequest({
30
+ payload: { model: 'openrouter/model-2', messages: [] },
121
31
  });
122
- expect(saveModelOverrides).not.toHaveBeenCalled();
123
- });
124
-
125
- it('rejects missing field assignments', async () => {
126
- const result = await handleModelOverrideSet('test/model --dry-run', emptyOverrides());
127
32
 
128
- expect(result.success).toBe(false);
129
- expect(result.message).toContain('No field assignments provided');
130
- expect(saveModelOverrides).not.toHaveBeenCalled();
131
- });
132
-
133
- it('rejects invalid assignments', async () => {
134
- const result = await handleModelOverrideSet('test/model unknown=value', emptyOverrides());
33
+ const result1 = addSessionIdToOpenRouterRequest(mockEvent1, mockCtx);
34
+ const result2 = addSessionIdToOpenRouterRequest(mockEvent2, mockCtx);
135
35
 
136
- expect(result.success).toBe(false);
137
- expect(result.message).toContain('Invalid assignment: "unknown=value"');
138
- expect(saveModelOverrides).not.toHaveBeenCalled();
36
+ expect(result1?.['session_id']).toBe('pi:stable-session-123');
37
+ expect(result2?.['session_id']).toBe('pi:stable-session-123');
38
+ expect(result1?.['session_id']).toBe(result2?.['session_id']);
139
39
  });
140
40
 
141
- it('does not create thinkingLevelMap for non-thinking overrides', async () => {
142
- const userOverrides = emptyOverrides();
143
-
144
- const result = await handleModelOverrideSet('test/model contextWindow=64000', userOverrides);
41
+ it('should not overwrite existing session_id in payload', async () => {
42
+ const { addSessionIdToOpenRouterRequest } = await import('../index.js');
145
43
 
146
- expect(result.success).toBe(true);
147
- expect(saveModelOverrides).toHaveBeenCalledWith({
148
- version: 1,
149
- overrides: {
150
- 'test/model': {
151
- contextWindow: 64000,
152
- },
44
+ const mockCtx = createSessionCtx('new-session');
45
+ const mockEvent = createOpenRouterRequest({
46
+ payload: {
47
+ model: 'openrouter/anthropic/claude-sonnet-4',
48
+ messages: [],
49
+ session_id: 'existing-session-id',
153
50
  },
154
51
  });
155
- });
156
-
157
- it('stores sparse thinking overrides without nulling unrelated levels', async () => {
158
- const userOverrides = emptyOverrides();
159
52
 
160
- const result = await handleModelOverrideSet('test/model thinking.high=high', userOverrides);
53
+ const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
161
54
 
162
- expect(result.success).toBe(true);
163
- expect(saveModelOverrides).toHaveBeenCalledWith({
164
- version: 1,
165
- overrides: {
166
- 'test/model': {
167
- thinkingLevelMap: {
168
- high: 'high',
169
- },
170
- },
171
- },
172
- });
55
+ expect(result).toBeUndefined();
56
+ expect(mockEvent['payload']?.['session_id']).toBe('existing-session-id');
173
57
  });
174
58
 
175
- it('persists all supported field types in one command', async () => {
176
- const userOverrides = emptyOverrides();
177
-
178
- const result = await handleModelOverrideSet(
179
- 'test/model thinking.high=high thinking.xhigh=max thinking.off=null contextWindow=64000 maxTokens=8192 reasoning=true',
180
- userOverrides,
181
- );
59
+ it('should not tag non-OpenRouter requests', async () => {
60
+ const { addSessionIdToOpenRouterRequest } = await import('../index.js');
182
61
 
183
- expect(result).toMatchObject({ success: true, modelId: 'test/model' });
184
- expect(result.message).toContain('Saved overrides for test/model');
185
- expect(saveModelOverrides).toHaveBeenCalledWith({
186
- version: 1,
187
- overrides: {
188
- 'test/model': {
189
- thinkingLevelMap: {
190
- high: 'high',
191
- xhigh: 'max',
192
- off: null,
193
- },
194
- contextWindow: 64000,
195
- maxTokens: 8192,
196
- reasoning: true,
197
- },
62
+ const mockCtx = createSessionCtx('my-session');
63
+ const mockEvent = createOpenRouterRequest({
64
+ provider: 'anthropic',
65
+ payload: {
66
+ model: 'claude-sonnet-4',
67
+ messages: [],
198
68
  },
199
69
  });
200
- });
201
70
 
202
- it('merges with existing overrides instead of replacing them', async () => {
203
- const userOverrides: ModelOverridesFile = {
204
- version: 1,
205
- overrides: {
206
- 'test/model': {
207
- thinkingLevelMap: { minimal: null, high: 'old-high' },
208
- contextWindow: 128000,
209
- },
210
- },
211
- };
71
+ const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
212
72
 
213
- const result = await handleModelOverrideSet(
214
- 'test/model thinking.high=high maxTokens=8192',
215
- userOverrides,
216
- );
217
-
218
- expect(result.success).toBe(true);
219
- expect(saveModelOverrides).toHaveBeenCalledWith({
220
- version: 1,
221
- overrides: {
222
- 'test/model': {
223
- thinkingLevelMap: { minimal: null, high: 'high' },
224
- contextWindow: 128000,
225
- maxTokens: 8192,
226
- },
227
- },
228
- });
73
+ expect(result).toBeUndefined();
229
74
  });
230
75
 
231
- it('returns a handler failure when saving overrides fails', async () => {
232
- const userOverrides = emptyOverrides();
233
- saveModelOverrides.mockRejectedValue(new Error('disk full'));
234
-
235
- const result = await handleModelOverrideSet('test/model contextWindow=64000', userOverrides);
76
+ it('should fail open when payload is missing', async () => {
77
+ const { addSessionIdToOpenRouterRequest } = await import('../index.js');
236
78
 
237
- expect(result).toEqual({
238
- success: false,
239
- message: 'Failed to save overrides for test/model: disk full',
240
- });
241
- });
242
- });
243
-
244
- describe('handleModelOverrideClear', () => {
245
- beforeEach(() => {
246
- saveModelOverrides.mockReset();
247
- saveModelOverrides.mockResolvedValue(undefined);
248
- });
79
+ const mockCtx = createSessionCtx('my-session');
80
+ const mockEvent = createOpenRouterRequest();
81
+ delete mockEvent['payload'];
249
82
 
250
- it('returns usage when no model id is provided', async () => {
251
- const result = await handleModelOverrideClear('', emptyOverrides());
83
+ const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
252
84
 
253
- expect(result).toEqual({
254
- success: false,
255
- message: 'Usage: /openrouter model-override-clear <model-id>',
256
- });
257
- expect(saveModelOverrides).not.toHaveBeenCalled();
85
+ expect(result).toBeUndefined();
258
86
  });
259
87
 
260
- it('rejects model IDs without provider/model format', async () => {
261
- const result = await handleModelOverrideClear('bad-model', emptyOverrides());
88
+ it('should generate fallback UUID when session manager throws', async () => {
89
+ const { addSessionIdToOpenRouterRequest } = await import('../index.js');
262
90
 
263
- expect(result).toEqual({
264
- success: false,
265
- message: 'Invalid model ID format: "bad-model"\nExpected format: provider/model',
91
+ const mockCtx = createSessionCtx(THROW_SESSION_ID);
92
+ const mockEvent = createOpenRouterRequest({
93
+ payload: {
94
+ model: 'openrouter/model',
95
+ messages: [],
96
+ },
266
97
  });
267
- expect(saveModelOverrides).not.toHaveBeenCalled();
268
- });
269
98
 
270
- it('reports when the model has no overrides', async () => {
271
- const result = await handleModelOverrideClear('test/model', emptyOverrides());
99
+ const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
272
100
 
273
- expect(result).toEqual({
274
- success: false,
275
- message: 'No overrides found for test/model',
276
- });
277
- expect(saveModelOverrides).not.toHaveBeenCalled();
101
+ expect(result).toBeDefined();
102
+ expect(result?.['session_id']).toMatch(
103
+ /^pi:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
104
+ );
278
105
  });
279
106
 
280
- it('removes an existing override and preserves other models', async () => {
281
- const userOverrides: ModelOverridesFile = {
282
- version: 1,
283
- overrides: {
284
- 'test/model': { contextWindow: 64000 },
285
- 'other/model': { maxTokens: 8192 },
286
- },
287
- };
288
-
289
- const result = await handleModelOverrideClear('test/model', userOverrides);
290
-
291
- expect(result).toEqual({
292
- success: true,
293
- message: 'Cleared all overrides for test/model',
294
- modelId: 'test/model',
295
- });
296
- expect(saveModelOverrides).toHaveBeenCalledWith({
297
- version: 1,
298
- overrides: {
299
- 'other/model': { maxTokens: 8192 },
300
- },
301
- });
302
- });
107
+ it('should fail open when payload getter throws', async () => {
108
+ const { addSessionIdToOpenRouterRequest } = await import('../index.js');
303
109
 
304
- it('returns a handler failure when clearing overrides fails', async () => {
305
- const userOverrides: ModelOverridesFile = {
306
- version: 1,
307
- overrides: {
308
- 'test/model': {
309
- contextWindow: 64000,
310
- },
110
+ const mockCtx = createSessionCtx('my-session');
111
+ const mockEvent = {
112
+ provider: 'openrouter',
113
+ get payload() {
114
+ throw new Error('Payload getter error');
311
115
  },
312
116
  };
313
- saveModelOverrides.mockRejectedValue(new Error('permission denied'));
314
-
315
- const result = await handleModelOverrideClear('test/model', userOverrides);
316
117
 
317
- expect(result).toEqual({
318
- success: false,
319
- message: 'Failed to clear overrides for test/model: permission denied',
320
- });
118
+ const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
119
+ expect(result).toBeUndefined();
321
120
  });
322
121
  });
323
122
 
324
- describe('handleModelOverrideList', () => {
123
+ describe('default extension export', () => {
325
124
  beforeEach(() => {
326
- loadModelOverrides.mockReset();
327
- loadModelOverrides.mockResolvedValue(emptyOverrides());
328
- });
329
-
330
- it('lists available fields', async () => {
331
- const result = await handleModelOverrideList('--fields');
332
-
333
- expect(result).toContain('Available override fields:');
334
- expect(result).toContain('thinking.high: thinkingLevelMap.high (string)');
335
- expect(result).toContain('contextWindow: contextWindow (number)');
336
- expect(result).toContain('reasoning: reasoning (boolean)');
337
- });
338
-
339
- it('shows an empty state when no overrides exist', async () => {
340
- const result = await handleModelOverrideList('');
341
-
342
- expect(result).toBe(
343
- 'No model overrides configured.\nUse /openrouter model-override-set to add overrides.',
344
- );
345
- });
346
-
347
- it('reports a missing specific model override', async () => {
348
- loadModelOverrides.mockResolvedValue({
349
- version: 1,
350
- overrides: {
351
- 'other/model': { maxTokens: 8192 },
352
- },
353
- });
354
-
355
- const result = await handleModelOverrideList('test/model');
356
-
357
- expect(result).toBe('No overrides configured for test/model');
358
- });
359
-
360
- it('formats a specific model override with nested thinking values', async () => {
361
- loadModelOverrides.mockResolvedValue({
362
- version: 1,
363
- overrides: {
364
- 'test/model': {
365
- thinkingLevelMap: { off: null, high: 'high', xhigh: 'max' },
366
- contextWindow: 64000,
367
- maxTokens: 8192,
368
- reasoning: true,
369
- },
370
- },
371
- });
372
-
373
- const result = await handleModelOverrideList('test/model');
374
-
375
- expect(result).toBe(
376
- [
377
- 'Overrides for test/model:',
378
- ' thinkingLevelMap:',
379
- ' off: null',
380
- ' high: high',
381
- ' xhigh: max',
382
- ' contextWindow: 64000',
383
- ' maxTokens: 8192',
384
- ' reasoning: true',
385
- ].join('\n'),
386
- );
387
- });
388
-
389
- it('lists all override model IDs and summarizes non-null thinking values', async () => {
390
- loadModelOverrides.mockResolvedValue({
391
- version: 1,
392
- overrides: {
393
- 'thinking/model': {
394
- thinkingLevelMap: { off: null, high: 'high', xhigh: 'max' },
395
- },
396
- 'top-level/model': {
397
- contextWindow: 64000,
398
- },
399
- },
400
- });
401
-
402
- const result = await handleModelOverrideList('');
403
-
404
- expect(result).toBe(
405
- [
406
- '2 model(s) with overrides:',
407
- ' thinking/model [high=high,xhigh=max]',
408
- ' top-level/model',
409
- '',
410
- 'Use /openrouter model-override-list <model-id> for details',
411
- ].join('\n'),
412
- );
125
+ vi.resetModules();
126
+ });
127
+
128
+ it('composes startup cache loading, hook installation, and command registration', async () => {
129
+ const initializeSessionState = vi.fn();
130
+ const loadStartupCacheState = vi
131
+ .fn()
132
+ .mockResolvedValue({ info: { count: 3, age: '1 minute' } });
133
+ const installOpenRouterHooks = vi.fn();
134
+ const registerOpenRouterCommands = vi.fn();
135
+
136
+ vi.doMock('../hooks.js', () => ({
137
+ addSessionIdToOpenRouterRequest: vi.fn(),
138
+ getCurrentSessionId: vi.fn(),
139
+ initializeSessionState,
140
+ loadStartupCacheState,
141
+ installOpenRouterHooks,
142
+ }));
143
+ vi.doMock('../commands.js', () => ({
144
+ registerOpenRouterCommands,
145
+ }));
146
+
147
+ const { default: openRouterExtension } = await import('../index.js');
148
+ const pi = {
149
+ on: vi.fn(),
150
+ registerCommand: vi.fn(),
151
+ registerProvider: vi.fn(),
152
+ } as any;
153
+
154
+ await openRouterExtension(pi);
155
+
156
+ expect(initializeSessionState).toHaveBeenCalledTimes(1);
157
+ expect(loadStartupCacheState).toHaveBeenCalledWith(pi);
158
+ expect(installOpenRouterHooks).toHaveBeenCalledWith(pi, {
159
+ info: { count: 3, age: '1 minute' },
160
+ });
161
+ expect(registerOpenRouterCommands).toHaveBeenCalledWith(pi);
413
162
  });
414
163
  });