@robhowley/pi-openrouter 0.9.0 → 0.10.0
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 +46 -5
- 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 +529 -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/__tests__/status-bar.test.ts +262 -0
- 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 +288 -0
- package/extensions/openrouter/index.ts +13 -990
- package/extensions/openrouter/local-usage.ts +158 -30
- 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/status-bar.ts +101 -0
- package/extensions/openrouter/types.ts +28 -9
- 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,529 @@
|
|
|
1
|
+
import { afterEach, 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
|
+
loadOpenRouterStatusBar: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../cache.js', () => ({
|
|
17
|
+
stopBackgroundRefresh: mocks.stopBackgroundRefresh,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('../session.js', () => ({
|
|
21
|
+
isOpenRouterRequest: mocks.isOpenRouterRequest,
|
|
22
|
+
formatSessionId: (value: string) => `pi:${value}`,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('../local-usage.js', () => ({
|
|
26
|
+
writeLocalUsage: mocks.writeLocalUsage,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('../models/cache.js', () => ({
|
|
30
|
+
loadCache: mocks.loadCache,
|
|
31
|
+
getCacheAgeMs: mocks.getCacheAgeMs,
|
|
32
|
+
formatDuration: mocks.formatDuration,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../models/mapper.js', () => ({
|
|
36
|
+
mapOpenRouterModels: mocks.mapOpenRouterModels,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock('../models/sync.js', () => ({
|
|
40
|
+
includeBuiltinRouterModels: mocks.includeBuiltinRouterModels,
|
|
41
|
+
isSyncEnabled: mocks.isSyncEnabled,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('../status-bar.js', () => ({
|
|
45
|
+
loadOpenRouterStatusBar: mocks.loadOpenRouterStatusBar,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
function createMockPi() {
|
|
49
|
+
const handlers = new Map<string, any>();
|
|
50
|
+
return {
|
|
51
|
+
handlers,
|
|
52
|
+
pi: {
|
|
53
|
+
on: vi.fn((event: string, handler: unknown) => {
|
|
54
|
+
handlers.set(event, handler);
|
|
55
|
+
}),
|
|
56
|
+
registerProvider: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createMockContext(overrides: { hasUI?: boolean } = {}) {
|
|
62
|
+
return {
|
|
63
|
+
hasUI: overrides.hasUI ?? true,
|
|
64
|
+
sessionManager: {
|
|
65
|
+
getSessionId: vi.fn(() => 'session-123'),
|
|
66
|
+
},
|
|
67
|
+
ui: {
|
|
68
|
+
notify: vi.fn(),
|
|
69
|
+
setStatus: vi.fn(),
|
|
70
|
+
theme: {
|
|
71
|
+
fg: vi.fn((_style: string, text: string) => `dim:${text}`),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
} as any;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createDeferredPromise<T>() {
|
|
78
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
79
|
+
const promise = new Promise<T>((res) => {
|
|
80
|
+
resolve = res;
|
|
81
|
+
});
|
|
82
|
+
return { promise, resolve };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function loadHooksModule() {
|
|
86
|
+
return import('../hooks.js');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('openrouter hooks', () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.resetModules();
|
|
92
|
+
vi.resetAllMocks();
|
|
93
|
+
vi.useRealTimers();
|
|
94
|
+
|
|
95
|
+
mocks.isOpenRouterRequest.mockReturnValue(true);
|
|
96
|
+
mocks.writeLocalUsage.mockResolvedValue(undefined);
|
|
97
|
+
mocks.loadCache.mockResolvedValue(null);
|
|
98
|
+
mocks.getCacheAgeMs.mockReturnValue(60000);
|
|
99
|
+
mocks.formatDuration.mockReturnValue('1 minute');
|
|
100
|
+
mocks.mapOpenRouterModels.mockResolvedValue({ configs: [{ id: 'model-a' }] });
|
|
101
|
+
mocks.includeBuiltinRouterModels.mockReturnValue([{ id: 'model-a' }, { id: 'router' }]);
|
|
102
|
+
mocks.isSyncEnabled.mockReturnValue(true);
|
|
103
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'empty' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('loads cached startup models and preserves startup cache info', async () => {
|
|
111
|
+
const { pi } = createMockPi();
|
|
112
|
+
const { loadStartupCacheState } = await loadHooksModule();
|
|
113
|
+
|
|
114
|
+
mocks.loadCache.mockResolvedValue({
|
|
115
|
+
models: [{ id: 'cached/model-a' }],
|
|
116
|
+
timestamp: Date.now() - 60000,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const startupState = await loadStartupCacheState(pi as any);
|
|
120
|
+
|
|
121
|
+
expect(pi.registerProvider).toHaveBeenCalledWith('openrouter', {
|
|
122
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
123
|
+
apiKey: 'OPENROUTER_API_KEY',
|
|
124
|
+
api: 'openai-completions',
|
|
125
|
+
models: [{ id: 'model-a' }, { id: 'router' }],
|
|
126
|
+
authHeader: true,
|
|
127
|
+
});
|
|
128
|
+
expect(startupState).toEqual({
|
|
129
|
+
info: {
|
|
130
|
+
count: 2,
|
|
131
|
+
age: '1 minute',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('keeps startup cache warning behavior unchanged when cached registration fails', async () => {
|
|
137
|
+
const { pi } = createMockPi();
|
|
138
|
+
const { loadStartupCacheState } = await loadHooksModule();
|
|
139
|
+
|
|
140
|
+
mocks.loadCache.mockResolvedValue({
|
|
141
|
+
models: [{ id: 'cached/model-a' }],
|
|
142
|
+
timestamp: Date.now() - 60000,
|
|
143
|
+
});
|
|
144
|
+
mocks.mapOpenRouterModels.mockRejectedValue(new Error('mapper failed'));
|
|
145
|
+
|
|
146
|
+
const startupState = await loadStartupCacheState(pi as any);
|
|
147
|
+
|
|
148
|
+
expect(pi.registerProvider).not.toHaveBeenCalled();
|
|
149
|
+
expect(startupState).toEqual({
|
|
150
|
+
warning: 'OpenRouter: cached models found but failed to register: mapper failed',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('installs the session-tagging hook only once across repeated installs', async () => {
|
|
155
|
+
const { pi } = createMockPi();
|
|
156
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
157
|
+
|
|
158
|
+
initializeSessionState();
|
|
159
|
+
installOpenRouterHooks(pi as any, {});
|
|
160
|
+
installOpenRouterHooks(pi as any, {});
|
|
161
|
+
|
|
162
|
+
expect(pi.on.mock.calls.filter(([event]) => event === 'before_provider_request')).toHaveLength(
|
|
163
|
+
1,
|
|
164
|
+
);
|
|
165
|
+
expect(pi.on.mock.calls.filter(([event]) => event === 'turn_end')).toHaveLength(2);
|
|
166
|
+
expect(pi.on.mock.calls.filter(([event]) => event === 'session_start')).toHaveLength(2);
|
|
167
|
+
expect(pi.on.mock.calls.filter(([event]) => event === 'session_shutdown')).toHaveLength(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('sets startup usage status from the local burn-rate helper and keeps cache notifications', async () => {
|
|
171
|
+
const { handlers, pi } = createMockPi();
|
|
172
|
+
const ctx = createMockContext();
|
|
173
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
174
|
+
|
|
175
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
176
|
+
kind: 'ready',
|
|
177
|
+
text: 'OR $2.14 today · 1.3x 30d avg',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
initializeSessionState();
|
|
181
|
+
installOpenRouterHooks(pi as any, {
|
|
182
|
+
info: { count: 5, age: '3 minutes' },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
186
|
+
|
|
187
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
188
|
+
await vi.waitFor(() => {
|
|
189
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
|
|
190
|
+
'openrouter',
|
|
191
|
+
'dim:OR $2.14 today · 1.3x 30d avg',
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
expect(String(ctx.ui.setStatus.mock.calls[0]?.[1] ?? '')).not.toContain('models');
|
|
195
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
196
|
+
'OpenRouter: 5 models loaded from cache (3 minutes old). Run /openrouter models-sync to refresh.',
|
|
197
|
+
'info',
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('clears stale startup status asynchronously when cached models exist but local spend is empty', async () => {
|
|
202
|
+
const { handlers, pi } = createMockPi();
|
|
203
|
+
const ctx = createMockContext();
|
|
204
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
205
|
+
const statusLoad = createDeferredPromise<{ kind: 'ready'; text: string } | { kind: 'empty' }>();
|
|
206
|
+
|
|
207
|
+
mocks.loadOpenRouterStatusBar.mockReturnValue(statusLoad.promise);
|
|
208
|
+
|
|
209
|
+
initializeSessionState();
|
|
210
|
+
installOpenRouterHooks(pi as any, {
|
|
211
|
+
info: { count: 5, age: '3 minutes' },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
215
|
+
|
|
216
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
218
|
+
'OpenRouter: 5 models loaded from cache (3 minutes old). Run /openrouter models-sync to refresh.',
|
|
219
|
+
'info',
|
|
220
|
+
);
|
|
221
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
statusLoad.resolve({ kind: 'empty' });
|
|
224
|
+
|
|
225
|
+
await vi.waitFor(() => {
|
|
226
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', undefined);
|
|
227
|
+
});
|
|
228
|
+
expect(ctx.ui.theme.fg).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('preserves the existing startup status when the usage helper fails open', async () => {
|
|
232
|
+
const { handlers, pi } = createMockPi();
|
|
233
|
+
const ctx = createMockContext();
|
|
234
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
235
|
+
|
|
236
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'failed' });
|
|
237
|
+
|
|
238
|
+
initializeSessionState();
|
|
239
|
+
installOpenRouterHooks(pi as any, {
|
|
240
|
+
warning: 'OpenRouter: cached models found but failed to register: mapper failed',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
244
|
+
|
|
245
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
246
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
247
|
+
'OpenRouter: cached models found but failed to register: mapper failed',
|
|
248
|
+
'warning',
|
|
249
|
+
);
|
|
250
|
+
await Promise.resolve();
|
|
251
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
252
|
+
expect(ctx.ui.theme.fg).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('keeps before_provider_request session tagging behavior unchanged through the installed hook', async () => {
|
|
256
|
+
const { handlers, pi } = createMockPi();
|
|
257
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
258
|
+
|
|
259
|
+
initializeSessionState();
|
|
260
|
+
installOpenRouterHooks(pi as any, {});
|
|
261
|
+
|
|
262
|
+
const result = handlers.get('before_provider_request')(
|
|
263
|
+
{
|
|
264
|
+
provider: 'openrouter',
|
|
265
|
+
payload: {
|
|
266
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
267
|
+
messages: [],
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
sessionManager: {
|
|
272
|
+
getSessionId: () => 'session-abc',
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(result).toEqual({
|
|
278
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
279
|
+
messages: [],
|
|
280
|
+
session_id: 'pi:session-abc',
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('refreshes the usage status after a successful OpenRouter turn_end local write without blocking the hook', async () => {
|
|
285
|
+
const { handlers, pi } = createMockPi();
|
|
286
|
+
const ctx = createMockContext();
|
|
287
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
288
|
+
const randomUuidSpy = vi
|
|
289
|
+
.spyOn(globalThis.crypto, 'randomUUID')
|
|
290
|
+
.mockReturnValue('11111111-1111-4111-8111-111111111111');
|
|
291
|
+
const writeLocalUsageDeferred = createDeferredPromise<void>();
|
|
292
|
+
|
|
293
|
+
mocks.writeLocalUsage.mockReturnValue(writeLocalUsageDeferred.promise);
|
|
294
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
295
|
+
kind: 'ready',
|
|
296
|
+
text: 'OR $1.25 today · 30.0x 30d avg',
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
initializeSessionState();
|
|
300
|
+
installOpenRouterHooks(pi as any, {});
|
|
301
|
+
|
|
302
|
+
await handlers.get('turn_end')(
|
|
303
|
+
{
|
|
304
|
+
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
305
|
+
message: {
|
|
306
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
307
|
+
responseId: 'resp-1',
|
|
308
|
+
usage: {
|
|
309
|
+
input: 11,
|
|
310
|
+
output: 7,
|
|
311
|
+
cacheRead: 3,
|
|
312
|
+
cacheWrite: 2,
|
|
313
|
+
cost: { total: 1.25 },
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
ctx,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
expect(mocks.writeLocalUsage).toHaveBeenCalledWith(
|
|
321
|
+
expect.objectContaining({
|
|
322
|
+
id: '11111111-1111-4111-8111-111111111111',
|
|
323
|
+
generationId: 'resp-1',
|
|
324
|
+
sessionId: 'pi:session-123',
|
|
325
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
326
|
+
requests: 1,
|
|
327
|
+
promptTokens: 11,
|
|
328
|
+
completionTokens: 7,
|
|
329
|
+
reasoningTokens: 0,
|
|
330
|
+
cacheReadTokens: 3,
|
|
331
|
+
cacheWriteTokens: 2,
|
|
332
|
+
cost: 1.25,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
336
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
337
|
+
|
|
338
|
+
writeLocalUsageDeferred.resolve();
|
|
339
|
+
|
|
340
|
+
await vi.waitFor(() => {
|
|
341
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
342
|
+
});
|
|
343
|
+
const writeCallOrder = mocks.writeLocalUsage.mock.invocationCallOrder[0] ?? 0;
|
|
344
|
+
const refreshCallOrder = mocks.loadOpenRouterStatusBar.mock.invocationCallOrder[0] ?? 0;
|
|
345
|
+
expect(writeCallOrder).toBeLessThan(refreshCallOrder);
|
|
346
|
+
await vi.waitFor(() => {
|
|
347
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
|
|
348
|
+
'openrouter',
|
|
349
|
+
'dim:OR $1.25 today · 30.0x 30d avg',
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
expect(String(ctx.ui.setStatus.mock.calls[0]?.[1] ?? '')).not.toContain('models');
|
|
353
|
+
|
|
354
|
+
randomUuidSpy.mockRestore();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('does not write local usage or update status for non-OpenRouter turns', async () => {
|
|
358
|
+
const { handlers, pi } = createMockPi();
|
|
359
|
+
const ctx = createMockContext();
|
|
360
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
361
|
+
|
|
362
|
+
mocks.isOpenRouterRequest.mockReturnValue(false);
|
|
363
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
364
|
+
kind: 'ready',
|
|
365
|
+
text: 'OR $9.99 today · 9.9x 30d avg',
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
initializeSessionState();
|
|
369
|
+
installOpenRouterHooks(pi as any, {});
|
|
370
|
+
|
|
371
|
+
await handlers.get('turn_end')(
|
|
372
|
+
{
|
|
373
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
374
|
+
message: {
|
|
375
|
+
model: 'claude-sonnet-4',
|
|
376
|
+
responseId: 'resp-2',
|
|
377
|
+
usage: {
|
|
378
|
+
input: 4,
|
|
379
|
+
output: 2,
|
|
380
|
+
cost: { total: 0.42 },
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
ctx,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(mocks.writeLocalUsage).not.toHaveBeenCalled();
|
|
388
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
389
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('clears stale status after a turn when the usage helper reports empty local spend', async () => {
|
|
393
|
+
const { handlers, pi } = createMockPi();
|
|
394
|
+
const ctx = createMockContext();
|
|
395
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
396
|
+
|
|
397
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'empty' });
|
|
398
|
+
|
|
399
|
+
initializeSessionState();
|
|
400
|
+
installOpenRouterHooks(pi as any, {});
|
|
401
|
+
|
|
402
|
+
await handlers.get('turn_end')(
|
|
403
|
+
{
|
|
404
|
+
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
405
|
+
message: {
|
|
406
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
407
|
+
responseId: 'resp-3',
|
|
408
|
+
usage: {
|
|
409
|
+
input: 1,
|
|
410
|
+
output: 1,
|
|
411
|
+
cost: { total: 0.01 },
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
ctx,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
await vi.waitFor(() => {
|
|
419
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', undefined);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('preserves the existing status after a turn when the usage helper fails open', async () => {
|
|
424
|
+
const { handlers, pi } = createMockPi();
|
|
425
|
+
const ctx = createMockContext();
|
|
426
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
427
|
+
|
|
428
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'failed' });
|
|
429
|
+
|
|
430
|
+
initializeSessionState();
|
|
431
|
+
installOpenRouterHooks(pi as any, {});
|
|
432
|
+
|
|
433
|
+
await handlers.get('turn_end')(
|
|
434
|
+
{
|
|
435
|
+
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
436
|
+
message: {
|
|
437
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
438
|
+
responseId: 'resp-4',
|
|
439
|
+
usage: {
|
|
440
|
+
input: 1,
|
|
441
|
+
output: 1,
|
|
442
|
+
cost: { total: 0.01 },
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
ctx,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
await Promise.resolve();
|
|
450
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
451
|
+
expect(ctx.ui.theme.fg).not.toHaveBeenCalled();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('refreshes the status at UTC midnight and reschedules while the session stays active', async () => {
|
|
455
|
+
vi.useFakeTimers();
|
|
456
|
+
vi.setSystemTime(new Date('2026-05-22T23:59:50.000Z'));
|
|
457
|
+
|
|
458
|
+
const { handlers, pi } = createMockPi();
|
|
459
|
+
const ctx = createMockContext();
|
|
460
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
461
|
+
|
|
462
|
+
mocks.loadOpenRouterStatusBar
|
|
463
|
+
.mockResolvedValueOnce({ kind: 'ready', text: 'OR $4.50 today · 2.0x 30d avg' })
|
|
464
|
+
.mockResolvedValueOnce({ kind: 'ready', text: 'OR $0.25 today · 0.2x 30d avg' })
|
|
465
|
+
.mockResolvedValueOnce({ kind: 'ready', text: 'OR $0.30 today · 0.2x 30d avg' });
|
|
466
|
+
|
|
467
|
+
initializeSessionState();
|
|
468
|
+
installOpenRouterHooks(pi as any, {});
|
|
469
|
+
|
|
470
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
471
|
+
|
|
472
|
+
await vi.waitFor(() => {
|
|
473
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
|
|
474
|
+
'openrouter',
|
|
475
|
+
'dim:OR $4.50 today · 2.0x 30d avg',
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
479
|
+
|
|
480
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
481
|
+
|
|
482
|
+
await vi.waitFor(() => {
|
|
483
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(2);
|
|
484
|
+
});
|
|
485
|
+
expect(ctx.ui.setStatus).toHaveBeenLastCalledWith(
|
|
486
|
+
'openrouter',
|
|
487
|
+
'dim:OR $0.25 today · 0.2x 30d avg',
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
|
491
|
+
|
|
492
|
+
await vi.waitFor(() => {
|
|
493
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(3);
|
|
494
|
+
});
|
|
495
|
+
expect(ctx.ui.setStatus).toHaveBeenLastCalledWith(
|
|
496
|
+
'openrouter',
|
|
497
|
+
'dim:OR $0.30 today · 0.2x 30d avg',
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('clears the UTC-midnight rollover timer on session shutdown', async () => {
|
|
502
|
+
vi.useFakeTimers();
|
|
503
|
+
vi.setSystemTime(new Date('2026-05-22T23:59:50.000Z'));
|
|
504
|
+
|
|
505
|
+
const { handlers, pi } = createMockPi();
|
|
506
|
+
const ctx = createMockContext();
|
|
507
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
508
|
+
|
|
509
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
510
|
+
kind: 'ready',
|
|
511
|
+
text: 'OR $1.00 today · 1.0x 30d avg',
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
initializeSessionState();
|
|
515
|
+
installOpenRouterHooks(pi as any, {});
|
|
516
|
+
|
|
517
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
518
|
+
await vi.waitFor(() => {
|
|
519
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
handlers.get('session_shutdown')();
|
|
523
|
+
|
|
524
|
+
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
|
525
|
+
|
|
526
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
527
|
+
expect(mocks.stopBackgroundRefresh).toHaveBeenCalledTimes(1);
|
|
528
|
+
});
|
|
529
|
+
});
|