@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
@@ -137,7 +137,8 @@ export function createPiModelConfig(overrides?: Partial<PiModelConfig>): PiModel
137
137
  // LocalUsageEvent Fixtures
138
138
  // =============================================================================
139
139
 
140
- import type { LocalUsageEvent } from '../types.js';
140
+ import type { LocalUsageEvent, UsageAggregate, UsageSummary } from '../types.js';
141
+ import { createZeroAggregate } from '../types.js';
141
142
 
142
143
  /**
143
144
  * Creates LocalUsageEvent objects for testing local usage aggregation.
@@ -159,3 +160,141 @@ export function createLocalEvents(
159
160
  cost: e.cost,
160
161
  }));
161
162
  }
163
+
164
+ // =============================================================================
165
+ // Session Context Fixtures
166
+ // =============================================================================
167
+
168
+ /**
169
+ * Special marker for session contexts that should throw when getSessionId is called.
170
+ */
171
+ export const THROW_SESSION_ID = Symbol('THROW_SESSION_ID');
172
+
173
+ /**
174
+ * Creates a mock context with a sessionManager for testing.
175
+ *
176
+ * @param sessionIdOrMarker - Session ID string, empty string, or THROW_SESSION_ID symbol
177
+ * @returns Context object with sessionManager.getSessionId() method
178
+ *
179
+ * @example
180
+ * // Normal session ID
181
+ * const ctx = createSessionCtx('my-session');
182
+ *
183
+ * // Empty string fallback
184
+ * const ctx = createSessionCtx('');
185
+ *
186
+ * // Throwing session manager
187
+ * const ctx = createSessionCtx(THROW_SESSION_ID);
188
+ */
189
+ export function createSessionCtx(sessionIdOrMarker: string | symbol): {
190
+ sessionManager: { getSessionId: () => string };
191
+ } {
192
+ if (sessionIdOrMarker === THROW_SESSION_ID) {
193
+ return {
194
+ sessionManager: {
195
+ getSessionId: () => {
196
+ throw new Error('Session manager error');
197
+ },
198
+ },
199
+ };
200
+ }
201
+ return {
202
+ sessionManager: {
203
+ getSessionId: () => sessionIdOrMarker as string,
204
+ },
205
+ };
206
+ }
207
+
208
+ // =============================================================================
209
+ // Request Event Fixtures
210
+ // =============================================================================
211
+
212
+ /**
213
+ * Creates an OpenRouter request event for testing.
214
+ *
215
+ * @param overrides - Override default provider, payload, or other event properties
216
+ * @returns Request event object suitable for hook tests
217
+ *
218
+ * @example
219
+ * // Default OpenRouter request
220
+ * const event = createOpenRouterRequest();
221
+ *
222
+ * // With custom model
223
+ * const event = createOpenRouterRequest({ payload: { model: 'custom/model', messages: [] } });
224
+ *
225
+ * // Non-OpenRouter provider
226
+ * const event = createOpenRouterRequest({ provider: 'anthropic' });
227
+ */
228
+ export function createOpenRouterRequest(overrides?: {
229
+ type?: string;
230
+ provider?: string;
231
+ payload?: Record<string, unknown>;
232
+ url?: string;
233
+ }): {
234
+ type: string;
235
+ provider: string;
236
+ payload?: Record<string, unknown>;
237
+ url?: string;
238
+ } {
239
+ return {
240
+ type: 'before_provider_request',
241
+ provider: 'openrouter',
242
+ payload: {
243
+ model: 'openrouter/anthropic/claude-sonnet-4',
244
+ messages: [],
245
+ },
246
+ ...overrides,
247
+ };
248
+ }
249
+
250
+ // =============================================================================
251
+ // UsageAggregate Fixtures
252
+ // =============================================================================
253
+
254
+ /**
255
+ * Creates a UsageAggregate with overrides.
256
+ * Starts with zeros and allows customizing specific fields.
257
+ */
258
+ export function createUsageAggregate(overrides?: Partial<UsageAggregate>): UsageAggregate {
259
+ return {
260
+ ...createZeroAggregate(),
261
+ ...overrides,
262
+ };
263
+ }
264
+
265
+ // =============================================================================
266
+ // UsageSummary Fixtures
267
+ // =============================================================================
268
+
269
+ /**
270
+ * Creates a UsageSummary with sensible defaults.
271
+ * Use overrides to customize for specific test cases.
272
+ *
273
+ * @example
274
+ * // Minimal summary
275
+ * const summary = createUsageSummary();
276
+ *
277
+ * // With custom today/local split
278
+ * const summary = createUsageSummary({
279
+ * today: 0.5,
280
+ * local: createUsageAggregate({ cost: 2.0, requests: 10 })
281
+ * });
282
+ */
283
+ export function createUsageSummary(overrides?: Partial<UsageSummary>): UsageSummary {
284
+ return {
285
+ today: 0,
286
+ week: 0,
287
+ month: 0,
288
+ cap: 10,
289
+ burnRate: 0,
290
+ topModels: [],
291
+ byProvider: [],
292
+ byDay: {},
293
+ timestamp: Date.now(),
294
+ hasActivityData: true,
295
+ official: createZeroAggregate(),
296
+ local: createZeroAggregate(),
297
+ combined: createZeroAggregate(),
298
+ ...overrides,
299
+ };
300
+ }
@@ -90,6 +90,25 @@ describe('aggregateUsage', () => {
90
90
  expect(result.byDay).toEqual({});
91
91
  });
92
92
 
93
+ it('should create fresh zero aggregates for official, local, and combined', () => {
94
+ const result = aggregateUsage({ totalUsage: 0, totalCredits: 0 }, []);
95
+
96
+ expect(result.official).toEqual({
97
+ requests: 0,
98
+ promptTokens: 0,
99
+ completionTokens: 0,
100
+ reasoningTokens: 0,
101
+ cacheReadTokens: 0,
102
+ cacheWriteTokens: 0,
103
+ cost: 0,
104
+ });
105
+ expect(result.local).toEqual(result.official);
106
+ expect(result.combined).toEqual(result.official);
107
+ expect(result.official).not.toBe(result.local);
108
+ expect(result.official).not.toBe(result.combined);
109
+ expect(result.local).not.toBe(result.combined);
110
+ });
111
+
93
112
  it('should aggregate by model', () => {
94
113
  const credits = {
95
114
  totalUsage: 10,
@@ -0,0 +1,276 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ stopBackgroundRefresh: vi.fn(),
5
+ isOpenRouterRequest: vi.fn(),
6
+ writeLocalUsage: vi.fn(),
7
+ loadCache: vi.fn(),
8
+ getCacheAgeMs: vi.fn(),
9
+ formatDuration: vi.fn(),
10
+ mapOpenRouterModels: vi.fn(),
11
+ includeBuiltinRouterModels: vi.fn(),
12
+ isSyncEnabled: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('../cache.js', () => ({
16
+ stopBackgroundRefresh: mocks.stopBackgroundRefresh,
17
+ }));
18
+
19
+ vi.mock('../session.js', () => ({
20
+ isOpenRouterRequest: mocks.isOpenRouterRequest,
21
+ formatSessionId: (value: string) => `pi:${value}`,
22
+ }));
23
+
24
+ vi.mock('../local-usage.js', () => ({
25
+ writeLocalUsage: mocks.writeLocalUsage,
26
+ }));
27
+
28
+ vi.mock('../models/cache.js', () => ({
29
+ loadCache: mocks.loadCache,
30
+ getCacheAgeMs: mocks.getCacheAgeMs,
31
+ formatDuration: mocks.formatDuration,
32
+ }));
33
+
34
+ vi.mock('../models/mapper.js', () => ({
35
+ mapOpenRouterModels: mocks.mapOpenRouterModels,
36
+ }));
37
+
38
+ vi.mock('../models/sync.js', () => ({
39
+ includeBuiltinRouterModels: mocks.includeBuiltinRouterModels,
40
+ isSyncEnabled: mocks.isSyncEnabled,
41
+ }));
42
+
43
+ function createMockPi() {
44
+ const handlers = new Map<string, any>();
45
+ return {
46
+ handlers,
47
+ pi: {
48
+ on: vi.fn((event: string, handler: unknown) => {
49
+ handlers.set(event, handler);
50
+ }),
51
+ registerProvider: vi.fn(),
52
+ },
53
+ };
54
+ }
55
+
56
+ function createMockContext(overrides: { hasUI?: boolean } = {}) {
57
+ return {
58
+ hasUI: overrides.hasUI ?? true,
59
+ sessionManager: {
60
+ getSessionId: vi.fn(() => 'session-123'),
61
+ },
62
+ ui: {
63
+ notify: vi.fn(),
64
+ setStatus: vi.fn(),
65
+ theme: {
66
+ fg: vi.fn((_style: string, text: string) => `dim:${text}`),
67
+ },
68
+ },
69
+ } as any;
70
+ }
71
+
72
+ async function loadHooksModule() {
73
+ return import('../hooks.js');
74
+ }
75
+
76
+ describe('openrouter hooks', () => {
77
+ beforeEach(() => {
78
+ vi.resetModules();
79
+ vi.resetAllMocks();
80
+
81
+ mocks.isOpenRouterRequest.mockReturnValue(true);
82
+ mocks.writeLocalUsage.mockResolvedValue(undefined);
83
+ mocks.loadCache.mockResolvedValue(null);
84
+ mocks.getCacheAgeMs.mockReturnValue(60000);
85
+ mocks.formatDuration.mockReturnValue('1 minute');
86
+ mocks.mapOpenRouterModels.mockResolvedValue({ configs: [{ id: 'model-a' }] });
87
+ mocks.includeBuiltinRouterModels.mockReturnValue([{ id: 'model-a' }, { id: 'router' }]);
88
+ mocks.isSyncEnabled.mockReturnValue(true);
89
+ });
90
+
91
+ it('loads cached startup models and preserves startup status text', async () => {
92
+ const { pi } = createMockPi();
93
+ const { loadStartupCacheState } = await loadHooksModule();
94
+
95
+ mocks.loadCache.mockResolvedValue({
96
+ models: [{ id: 'cached/model-a' }],
97
+ timestamp: Date.now() - 60000,
98
+ });
99
+
100
+ const startupState = await loadStartupCacheState(pi as any);
101
+
102
+ expect(pi.registerProvider).toHaveBeenCalledWith('openrouter', {
103
+ baseUrl: 'https://openrouter.ai/api/v1',
104
+ apiKey: 'OPENROUTER_API_KEY',
105
+ api: 'openai-completions',
106
+ models: [{ id: 'model-a' }, { id: 'router' }],
107
+ authHeader: true,
108
+ });
109
+ expect(startupState).toEqual({
110
+ info: {
111
+ count: 2,
112
+ age: '1 minute',
113
+ },
114
+ });
115
+ });
116
+
117
+ it('keeps startup cache warning behavior unchanged when cached registration fails', async () => {
118
+ const { pi } = createMockPi();
119
+ const { loadStartupCacheState } = await loadHooksModule();
120
+
121
+ mocks.loadCache.mockResolvedValue({
122
+ models: [{ id: 'cached/model-a' }],
123
+ timestamp: Date.now() - 60000,
124
+ });
125
+ mocks.mapOpenRouterModels.mockRejectedValue(new Error('mapper failed'));
126
+
127
+ const startupState = await loadStartupCacheState(pi as any);
128
+
129
+ expect(pi.registerProvider).not.toHaveBeenCalled();
130
+ expect(startupState).toEqual({
131
+ warning: 'OpenRouter: cached models found but failed to register: mapper failed',
132
+ });
133
+ });
134
+
135
+ it('installs the session-tagging hook only once across repeated installs', async () => {
136
+ const { pi } = createMockPi();
137
+ const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
138
+
139
+ initializeSessionState();
140
+ installOpenRouterHooks(pi as any, {});
141
+ installOpenRouterHooks(pi as any, {});
142
+
143
+ expect(pi.on.mock.calls.filter(([event]) => event === 'before_provider_request')).toHaveLength(
144
+ 1,
145
+ );
146
+ expect(pi.on.mock.calls.filter(([event]) => event === 'turn_end')).toHaveLength(2);
147
+ expect(pi.on.mock.calls.filter(([event]) => event === 'session_start')).toHaveLength(2);
148
+ expect(pi.on.mock.calls.filter(([event]) => event === 'session_shutdown')).toHaveLength(2);
149
+ });
150
+
151
+ it('keeps session_start status and startup notifications unchanged', async () => {
152
+ const { handlers, pi } = createMockPi();
153
+ const ctx = createMockContext();
154
+ const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
155
+
156
+ initializeSessionState();
157
+ installOpenRouterHooks(pi as any, {
158
+ info: { count: 5, age: '3 minutes' },
159
+ });
160
+
161
+ handlers.get('session_start')({ reason: 'startup' }, ctx);
162
+
163
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', 'dim:OpenRouter 5 models');
164
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
165
+ 'OpenRouter: 5 models loaded from cache (3 minutes old). Run /openrouter models-sync to refresh.',
166
+ 'info',
167
+ );
168
+ });
169
+
170
+ it('keeps startup warning notifications unchanged', async () => {
171
+ const { handlers, pi } = createMockPi();
172
+ const ctx = createMockContext();
173
+ const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
174
+
175
+ initializeSessionState();
176
+ installOpenRouterHooks(pi as any, {
177
+ warning: 'OpenRouter: cached models found but failed to register: mapper failed',
178
+ });
179
+
180
+ handlers.get('session_start')({ reason: 'startup' }, ctx);
181
+
182
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
183
+ 'OpenRouter: cached models found but failed to register: mapper failed',
184
+ 'warning',
185
+ );
186
+ });
187
+
188
+ it('keeps before_provider_request session tagging behavior unchanged through the installed hook', async () => {
189
+ const { handlers, pi } = createMockPi();
190
+ const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
191
+
192
+ initializeSessionState();
193
+ installOpenRouterHooks(pi as any, {});
194
+
195
+ const result = handlers.get('before_provider_request')(
196
+ {
197
+ provider: 'openrouter',
198
+ payload: {
199
+ model: 'openrouter/anthropic/claude-sonnet-4',
200
+ messages: [],
201
+ },
202
+ },
203
+ {
204
+ sessionManager: {
205
+ getSessionId: () => 'session-abc',
206
+ },
207
+ },
208
+ );
209
+
210
+ expect(result).toEqual({
211
+ model: 'openrouter/anthropic/claude-sonnet-4',
212
+ messages: [],
213
+ session_id: 'pi:session-abc',
214
+ });
215
+ });
216
+
217
+ it('keeps turn_end local usage logging unchanged', async () => {
218
+ const { handlers, pi } = createMockPi();
219
+ const ctx = createMockContext();
220
+ const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
221
+ const randomUuidSpy = vi
222
+ .spyOn(globalThis.crypto, 'randomUUID')
223
+ .mockReturnValue('11111111-1111-4111-8111-111111111111');
224
+
225
+ initializeSessionState();
226
+ installOpenRouterHooks(pi as any, {});
227
+
228
+ await handlers.get('turn_end')(
229
+ {
230
+ url: 'https://openrouter.ai/api/v1/chat/completions',
231
+ message: {
232
+ model: 'openrouter/anthropic/claude-sonnet-4',
233
+ responseId: 'resp-1',
234
+ usage: {
235
+ input: 11,
236
+ output: 7,
237
+ cacheRead: 3,
238
+ cacheWrite: 2,
239
+ cost: { total: 1.25 },
240
+ },
241
+ },
242
+ },
243
+ ctx,
244
+ );
245
+
246
+ expect(mocks.writeLocalUsage).toHaveBeenCalledWith(
247
+ expect.objectContaining({
248
+ id: '11111111-1111-4111-8111-111111111111',
249
+ generationId: 'resp-1',
250
+ sessionId: 'pi:session-123',
251
+ model: 'openrouter/anthropic/claude-sonnet-4',
252
+ requests: 1,
253
+ promptTokens: 11,
254
+ completionTokens: 7,
255
+ reasoningTokens: 0,
256
+ cacheReadTokens: 3,
257
+ cacheWriteTokens: 2,
258
+ cost: 1.25,
259
+ }),
260
+ );
261
+
262
+ randomUuidSpy.mockRestore();
263
+ });
264
+
265
+ it('stops background refresh on session shutdown', async () => {
266
+ const { handlers, pi } = createMockPi();
267
+ const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
268
+
269
+ initializeSessionState();
270
+ installOpenRouterHooks(pi as any, {});
271
+
272
+ handlers.get('session_shutdown')();
273
+
274
+ expect(mocks.stopBackgroundRefresh).toHaveBeenCalledTimes(1);
275
+ });
276
+ });