@robhowley/pi-openrouter 0.12.1 → 0.14.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 +14 -0
- package/extensions/openrouter/__tests__/config.test.ts +173 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +242 -2
- package/extensions/openrouter/__tests__/index.test.ts +17 -116
- package/extensions/openrouter/__tests__/local-usage.test.ts +10 -7
- package/extensions/openrouter/config.ts +44 -0
- package/extensions/openrouter/hooks.ts +26 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,6 +168,20 @@ The extension logs completed OpenRouter turns to local JSONL files in `~/.pi/ope
|
|
|
168
168
|
|
|
169
169
|
When positive local spend exists in the last 30 UTC days, the footer/status bar shows local-only Today spend and a 30-day average multiplier, for example `OR $2.14 today · 1.3x 30d avg`.
|
|
170
170
|
|
|
171
|
+
To hide only the footer/status bar, set `pi-openrouter.statusEnabled` to `false` in either `~/.pi/agent/settings.json` or `.pi/settings.json`:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"pi-openrouter": {
|
|
176
|
+
"statusEnabled": false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The default is enabled. Setting `false` hides only the footer/status bar; `/openrouter usage`, `/openrouter models-status`, model sync, and local usage tracking remain available.
|
|
182
|
+
|
|
183
|
+
Project-local `.pi/settings.json` only overrides the global setting when Pi treats the project as trusted. If trust state is unavailable, `pi-openrouter` falls back to the global setting.
|
|
184
|
+
|
|
171
185
|
Model count and cache health remain available through `/openrouter models-status`.
|
|
172
186
|
|
|
173
187
|
**Retention:** Local usage files are automatically cleaned up after 90 days.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { isStatusEnabled, loadOpenRouterConfig } from '../config.js';
|
|
6
|
+
|
|
7
|
+
function writeJsonFile(path: string, value: unknown) {
|
|
8
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
9
|
+
writeFileSync(path, JSON.stringify(value, null, 2), 'utf-8');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe.sequential('openrouter config', () => {
|
|
13
|
+
let originalAgentDir: string | undefined;
|
|
14
|
+
let testRoot: string;
|
|
15
|
+
let cwd: string;
|
|
16
|
+
let agentDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
originalAgentDir = process.env['PI_CODING_AGENT_DIR'];
|
|
20
|
+
testRoot = mkdtempSync(join(tmpdir(), 'pi-openrouter-config-'));
|
|
21
|
+
cwd = join(testRoot, 'repo');
|
|
22
|
+
agentDir = join(testRoot, 'agent');
|
|
23
|
+
mkdirSync(cwd, { recursive: true });
|
|
24
|
+
process.env['PI_CODING_AGENT_DIR'] = agentDir;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (originalAgentDir === undefined) {
|
|
29
|
+
delete process.env['PI_CODING_AGENT_DIR'];
|
|
30
|
+
} else {
|
|
31
|
+
process.env['PI_CODING_AGENT_DIR'] = originalAgentDir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function writeGlobalSettings(settings: unknown) {
|
|
38
|
+
writeJsonFile(join(agentDir, 'settings.json'), settings);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeProjectSettings(settings: unknown) {
|
|
42
|
+
writeJsonFile(join(cwd, '.pi', 'settings.json'), settings);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('defaults statusEnabled to true when settings are absent', () => {
|
|
46
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
47
|
+
statusEnabled: true,
|
|
48
|
+
});
|
|
49
|
+
expect(isStatusEnabled(cwd)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('lets a global false disable status', () => {
|
|
53
|
+
writeGlobalSettings({
|
|
54
|
+
'pi-openrouter': {
|
|
55
|
+
statusEnabled: false,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
60
|
+
statusEnabled: false,
|
|
61
|
+
});
|
|
62
|
+
expect(isStatusEnabled(cwd)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('lets project false override global true', () => {
|
|
66
|
+
writeGlobalSettings({
|
|
67
|
+
'pi-openrouter': {
|
|
68
|
+
statusEnabled: true,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
writeProjectSettings({
|
|
72
|
+
'pi-openrouter': {
|
|
73
|
+
statusEnabled: false,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
78
|
+
statusEnabled: false,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('lets project true override global false', () => {
|
|
83
|
+
writeGlobalSettings({
|
|
84
|
+
'pi-openrouter': {
|
|
85
|
+
statusEnabled: false,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
writeProjectSettings({
|
|
89
|
+
'pi-openrouter': {
|
|
90
|
+
statusEnabled: true,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
95
|
+
statusEnabled: true,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps a global false when trusted project-local settings are malformed', () => {
|
|
100
|
+
writeGlobalSettings({
|
|
101
|
+
'pi-openrouter': {
|
|
102
|
+
statusEnabled: false,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
writeProjectSettings({
|
|
106
|
+
'pi-openrouter': {
|
|
107
|
+
statusEnabled: 'invalid',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
112
|
+
statusEnabled: false,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
writeProjectSettings({
|
|
116
|
+
'pi-openrouter': 'invalid',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
120
|
+
statusEnabled: false,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('defaults invalid or unset values to true when neither scope has a valid boolean', () => {
|
|
125
|
+
writeGlobalSettings({
|
|
126
|
+
'pi-openrouter': {
|
|
127
|
+
statusEnabled: 'invalid',
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
132
|
+
statusEnabled: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
writeGlobalSettings({
|
|
136
|
+
'pi-openrouter': 'invalid',
|
|
137
|
+
});
|
|
138
|
+
writeProjectSettings({});
|
|
139
|
+
|
|
140
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
141
|
+
statusEnabled: true,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
writeGlobalSettings({});
|
|
145
|
+
writeProjectSettings({
|
|
146
|
+
'pi-openrouter': {
|
|
147
|
+
statusEnabled: 'invalid',
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(loadOpenRouterConfig(cwd)).toEqual({
|
|
152
|
+
statusEnabled: true,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('ignores project-local overrides when the project is untrusted', () => {
|
|
157
|
+
writeGlobalSettings({
|
|
158
|
+
'pi-openrouter': {
|
|
159
|
+
statusEnabled: true,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
writeProjectSettings({
|
|
163
|
+
'pi-openrouter': {
|
|
164
|
+
statusEnabled: false,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(loadOpenRouterConfig(cwd, false)).toEqual({
|
|
169
|
+
statusEnabled: true,
|
|
170
|
+
});
|
|
171
|
+
expect(isStatusEnabled(cwd, false)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createOpenRouterRequest, createSessionCtx, THROW_SESSION_ID } from './fixtures.js';
|
|
2
3
|
|
|
3
4
|
const mocks = vi.hoisted(() => ({
|
|
4
5
|
stopBackgroundRefresh: vi.fn(),
|
|
@@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
13
14
|
isSyncEnabled: vi.fn(),
|
|
14
15
|
setActiveCatalogState: vi.fn(),
|
|
15
16
|
loadOpenRouterStatusBar: vi.fn(),
|
|
17
|
+
isStatusEnabled: vi.fn(),
|
|
16
18
|
}));
|
|
17
19
|
|
|
18
20
|
vi.mock('../cache.js', () => ({
|
|
@@ -49,6 +51,10 @@ vi.mock('../status-bar.js', () => ({
|
|
|
49
51
|
loadOpenRouterStatusBar: mocks.loadOpenRouterStatusBar,
|
|
50
52
|
}));
|
|
51
53
|
|
|
54
|
+
vi.mock('../config.js', () => ({
|
|
55
|
+
isStatusEnabled: mocks.isStatusEnabled,
|
|
56
|
+
}));
|
|
57
|
+
|
|
52
58
|
function createMockPi() {
|
|
53
59
|
const handlers = new Map<string, any>();
|
|
54
60
|
return {
|
|
@@ -62,8 +68,11 @@ function createMockPi() {
|
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
function createMockContext(
|
|
66
|
-
|
|
71
|
+
function createMockContext(
|
|
72
|
+
overrides: { cwd?: string; hasUI?: boolean; projectTrusted?: boolean } = {},
|
|
73
|
+
) {
|
|
74
|
+
const ctx = {
|
|
75
|
+
cwd: overrides.cwd ?? '/repo',
|
|
67
76
|
hasUI: overrides.hasUI ?? true,
|
|
68
77
|
sessionManager: {
|
|
69
78
|
getSessionId: vi.fn(() => 'session-123'),
|
|
@@ -76,6 +85,12 @@ function createMockContext(overrides: { hasUI?: boolean } = {}) {
|
|
|
76
85
|
},
|
|
77
86
|
},
|
|
78
87
|
} as any;
|
|
88
|
+
|
|
89
|
+
if (overrides.projectTrusted !== undefined) {
|
|
90
|
+
ctx.isProjectTrusted = vi.fn(() => overrides.projectTrusted);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return ctx;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
function createDeferredPromise<T>() {
|
|
@@ -110,12 +125,130 @@ describe('openrouter hooks', () => {
|
|
|
110
125
|
mocks.includeBuiltinRouterModels.mockReturnValue([{ id: 'model-a' }, { id: 'router' }]);
|
|
111
126
|
mocks.isSyncEnabled.mockReturnValue(true);
|
|
112
127
|
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'empty' });
|
|
128
|
+
mocks.isStatusEnabled.mockReturnValue(true);
|
|
113
129
|
});
|
|
114
130
|
|
|
115
131
|
afterEach(() => {
|
|
116
132
|
vi.useRealTimers();
|
|
117
133
|
});
|
|
118
134
|
|
|
135
|
+
describe('addSessionIdToOpenRouterRequest', () => {
|
|
136
|
+
it('adds session_id to OpenRouter requests', async () => {
|
|
137
|
+
const { addSessionIdToOpenRouterRequest } = await loadHooksModule();
|
|
138
|
+
|
|
139
|
+
const result = addSessionIdToOpenRouterRequest(
|
|
140
|
+
createOpenRouterRequest(),
|
|
141
|
+
createSessionCtx('stable-session-123'),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(result).toEqual({
|
|
145
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
146
|
+
messages: [],
|
|
147
|
+
session_id: 'pi:stable-session-123',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns same session_id for multiple OpenRouter requests in same session', async () => {
|
|
152
|
+
const { addSessionIdToOpenRouterRequest } = await loadHooksModule();
|
|
153
|
+
const ctx = createSessionCtx('stable-session-123');
|
|
154
|
+
|
|
155
|
+
const result1 = addSessionIdToOpenRouterRequest(
|
|
156
|
+
createOpenRouterRequest({
|
|
157
|
+
payload: { model: 'openrouter/model-1', messages: [] },
|
|
158
|
+
}),
|
|
159
|
+
ctx,
|
|
160
|
+
);
|
|
161
|
+
const result2 = addSessionIdToOpenRouterRequest(
|
|
162
|
+
createOpenRouterRequest({
|
|
163
|
+
payload: { model: 'openrouter/model-2', messages: [] },
|
|
164
|
+
}),
|
|
165
|
+
ctx,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(result1?.['session_id']).toBe('pi:stable-session-123');
|
|
169
|
+
expect(result2?.['session_id']).toBe('pi:stable-session-123');
|
|
170
|
+
expect(result1?.['session_id']).toBe(result2?.['session_id']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('does not overwrite existing session_id in payload', async () => {
|
|
174
|
+
const { addSessionIdToOpenRouterRequest } = await loadHooksModule();
|
|
175
|
+
const event = createOpenRouterRequest({
|
|
176
|
+
payload: {
|
|
177
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
178
|
+
messages: [],
|
|
179
|
+
session_id: 'existing-session-id',
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const result = addSessionIdToOpenRouterRequest(event, createSessionCtx('new-session'));
|
|
184
|
+
|
|
185
|
+
expect(result).toBeUndefined();
|
|
186
|
+
expect(event.payload?.['session_id']).toBe('existing-session-id');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does not tag non-OpenRouter requests', async () => {
|
|
190
|
+
mocks.isOpenRouterRequest.mockReturnValue(false);
|
|
191
|
+
const { addSessionIdToOpenRouterRequest } = await loadHooksModule();
|
|
192
|
+
|
|
193
|
+
const result = addSessionIdToOpenRouterRequest(
|
|
194
|
+
createOpenRouterRequest({
|
|
195
|
+
provider: 'anthropic',
|
|
196
|
+
payload: {
|
|
197
|
+
model: 'claude-sonnet-4',
|
|
198
|
+
messages: [],
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
createSessionCtx('my-session'),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('fails open when payload is missing', async () => {
|
|
208
|
+
const { addSessionIdToOpenRouterRequest } = await loadHooksModule();
|
|
209
|
+
const event = createOpenRouterRequest();
|
|
210
|
+
delete event.payload;
|
|
211
|
+
|
|
212
|
+
const result = addSessionIdToOpenRouterRequest(event, createSessionCtx('my-session'));
|
|
213
|
+
|
|
214
|
+
expect(result).toBeUndefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('generates fallback UUID when session manager throws', async () => {
|
|
218
|
+
const { addSessionIdToOpenRouterRequest } = await loadHooksModule();
|
|
219
|
+
|
|
220
|
+
const result = addSessionIdToOpenRouterRequest(
|
|
221
|
+
createOpenRouterRequest({
|
|
222
|
+
payload: {
|
|
223
|
+
model: 'openrouter/model',
|
|
224
|
+
messages: [],
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
createSessionCtx(THROW_SESSION_ID),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(result?.['session_id']).toMatch(
|
|
231
|
+
/^pi:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('fails open when payload getter throws', async () => {
|
|
236
|
+
const { addSessionIdToOpenRouterRequest } = await loadHooksModule();
|
|
237
|
+
|
|
238
|
+
const result = addSessionIdToOpenRouterRequest(
|
|
239
|
+
{
|
|
240
|
+
provider: 'openrouter',
|
|
241
|
+
get payload() {
|
|
242
|
+
throw new Error('Payload getter error');
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
createSessionCtx('my-session'),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(result).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
119
252
|
it('loads cached startup models, respects cached mode, and seeds active catalog state', async () => {
|
|
120
253
|
const { pi } = createMockPi();
|
|
121
254
|
const { loadStartupCacheState } = await loadHooksModule();
|
|
@@ -277,6 +410,43 @@ describe('openrouter hooks', () => {
|
|
|
277
410
|
);
|
|
278
411
|
});
|
|
279
412
|
|
|
413
|
+
it('clears the footer status without loading local spend when status is disabled', async () => {
|
|
414
|
+
const { handlers, pi } = createMockPi();
|
|
415
|
+
const ctx = createMockContext({ projectTrusted: false });
|
|
416
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
417
|
+
|
|
418
|
+
mocks.isStatusEnabled.mockReturnValue(false);
|
|
419
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
420
|
+
kind: 'ready',
|
|
421
|
+
text: 'OR $2.14 today · 1.3x 30d avg',
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
initializeSessionState();
|
|
425
|
+
installOpenRouterHooks(pi as any, {});
|
|
426
|
+
|
|
427
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
428
|
+
|
|
429
|
+
expect(mocks.isStatusEnabled).toHaveBeenCalledWith('/repo', false);
|
|
430
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
431
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', undefined);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('uses the conservative global-only path when trust state is unavailable from context', async () => {
|
|
435
|
+
const { handlers, pi } = createMockPi();
|
|
436
|
+
const ctx = createMockContext();
|
|
437
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
438
|
+
|
|
439
|
+
mocks.isStatusEnabled.mockReturnValue(false);
|
|
440
|
+
|
|
441
|
+
initializeSessionState();
|
|
442
|
+
installOpenRouterHooks(pi as any, {});
|
|
443
|
+
|
|
444
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
445
|
+
|
|
446
|
+
expect(mocks.isStatusEnabled).toHaveBeenCalledWith('/repo', false);
|
|
447
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
448
|
+
});
|
|
449
|
+
|
|
280
450
|
it('clears stale startup status asynchronously when cached models exist but local spend is empty', async () => {
|
|
281
451
|
const { handlers, pi } = createMockPi();
|
|
282
452
|
const ctx = createMockContext();
|
|
@@ -433,6 +603,48 @@ describe('openrouter hooks', () => {
|
|
|
433
603
|
randomUuidSpy.mockRestore();
|
|
434
604
|
});
|
|
435
605
|
|
|
606
|
+
it('still writes local usage after a turn when status is disabled', async () => {
|
|
607
|
+
const { handlers, pi } = createMockPi();
|
|
608
|
+
const ctx = createMockContext();
|
|
609
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
610
|
+
const randomUuidSpy = vi
|
|
611
|
+
.spyOn(globalThis.crypto, 'randomUUID')
|
|
612
|
+
.mockReturnValue('22222222-2222-4222-8222-222222222222');
|
|
613
|
+
|
|
614
|
+
mocks.isStatusEnabled.mockReturnValue(false);
|
|
615
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
616
|
+
kind: 'ready',
|
|
617
|
+
text: 'OR $9.99 today · 9.9x 30d avg',
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
initializeSessionState();
|
|
621
|
+
installOpenRouterHooks(pi as any, {});
|
|
622
|
+
|
|
623
|
+
await handlers.get('turn_end')(
|
|
624
|
+
{
|
|
625
|
+
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
626
|
+
message: {
|
|
627
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
628
|
+
responseId: 'resp-disabled',
|
|
629
|
+
usage: {
|
|
630
|
+
input: 4,
|
|
631
|
+
output: 2,
|
|
632
|
+
cost: { total: 0.42 },
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
ctx,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
expect(mocks.writeLocalUsage).toHaveBeenCalledTimes(1);
|
|
640
|
+
await vi.waitFor(() => {
|
|
641
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', undefined);
|
|
642
|
+
});
|
|
643
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
644
|
+
|
|
645
|
+
randomUuidSpy.mockRestore();
|
|
646
|
+
});
|
|
647
|
+
|
|
436
648
|
it('does not write local usage or update status for non-OpenRouter turns', async () => {
|
|
437
649
|
const { handlers, pi } = createMockPi();
|
|
438
650
|
const ctx = createMockContext();
|
|
@@ -530,6 +742,34 @@ describe('openrouter hooks', () => {
|
|
|
530
742
|
expect(ctx.ui.theme.fg).not.toHaveBeenCalled();
|
|
531
743
|
});
|
|
532
744
|
|
|
745
|
+
it('does not schedule the UTC-midnight rollover when status is disabled', async () => {
|
|
746
|
+
vi.useFakeTimers();
|
|
747
|
+
vi.setSystemTime(new Date('2026-05-22T23:59:50.000Z'));
|
|
748
|
+
|
|
749
|
+
const { handlers, pi } = createMockPi();
|
|
750
|
+
const ctx = createMockContext();
|
|
751
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
752
|
+
|
|
753
|
+
mocks.isStatusEnabled.mockReturnValue(false);
|
|
754
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
755
|
+
kind: 'ready',
|
|
756
|
+
text: 'OR $4.50 today · 2.0x 30d avg',
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
initializeSessionState();
|
|
760
|
+
installOpenRouterHooks(pi as any, {});
|
|
761
|
+
|
|
762
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
763
|
+
|
|
764
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', undefined);
|
|
765
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
766
|
+
|
|
767
|
+
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
|
768
|
+
|
|
769
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledTimes(1);
|
|
770
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
771
|
+
});
|
|
772
|
+
|
|
533
773
|
it('refreshes the status at UTC midnight and reschedules while the session stays active', async () => {
|
|
534
774
|
vi.useFakeTimers();
|
|
535
775
|
vi.setSystemTime(new Date('2026-05-22T23:59:50.000Z'));
|
|
@@ -1,128 +1,29 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { createSessionCtx, createOpenRouterRequest, THROW_SESSION_ID } from './fixtures.js';
|
|
3
2
|
|
|
4
|
-
describe('
|
|
3
|
+
describe('openrouter index entrypoint', () => {
|
|
5
4
|
beforeEach(() => {
|
|
6
5
|
vi.resetModules();
|
|
7
6
|
});
|
|
8
7
|
|
|
9
|
-
it('
|
|
10
|
-
const
|
|
8
|
+
it('re-exports named helpers from hooks.js', async () => {
|
|
9
|
+
const addSessionIdToOpenRouterRequest = vi.fn();
|
|
10
|
+
const getCurrentSessionId = vi.fn();
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
it('should return same session_id for multiple OpenRouter requests in same session', async () => {
|
|
23
|
-
const { addSessionIdToOpenRouterRequest } = await import('../index.js');
|
|
24
|
-
|
|
25
|
-
const mockCtx = createSessionCtx('stable-session-123');
|
|
26
|
-
const mockEvent1 = createOpenRouterRequest({
|
|
27
|
-
payload: { model: 'openrouter/model-1', messages: [] },
|
|
28
|
-
});
|
|
29
|
-
const mockEvent2 = createOpenRouterRequest({
|
|
30
|
-
payload: { model: 'openrouter/model-2', messages: [] },
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const result1 = addSessionIdToOpenRouterRequest(mockEvent1, mockCtx);
|
|
34
|
-
const result2 = addSessionIdToOpenRouterRequest(mockEvent2, mockCtx);
|
|
35
|
-
|
|
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']);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('should not overwrite existing session_id in payload', async () => {
|
|
42
|
-
const { addSessionIdToOpenRouterRequest } = await import('../index.js');
|
|
43
|
-
|
|
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',
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
|
|
54
|
-
|
|
55
|
-
expect(result).toBeUndefined();
|
|
56
|
-
expect(mockEvent['payload']?.['session_id']).toBe('existing-session-id');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('should not tag non-OpenRouter requests', async () => {
|
|
60
|
-
const { addSessionIdToOpenRouterRequest } = await import('../index.js');
|
|
61
|
-
|
|
62
|
-
const mockCtx = createSessionCtx('my-session');
|
|
63
|
-
const mockEvent = createOpenRouterRequest({
|
|
64
|
-
provider: 'anthropic',
|
|
65
|
-
payload: {
|
|
66
|
-
model: 'claude-sonnet-4',
|
|
67
|
-
messages: [],
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
|
|
72
|
-
|
|
73
|
-
expect(result).toBeUndefined();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should fail open when payload is missing', async () => {
|
|
77
|
-
const { addSessionIdToOpenRouterRequest } = await import('../index.js');
|
|
78
|
-
|
|
79
|
-
const mockCtx = createSessionCtx('my-session');
|
|
80
|
-
const mockEvent = createOpenRouterRequest();
|
|
81
|
-
delete mockEvent['payload'];
|
|
82
|
-
|
|
83
|
-
const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
|
|
84
|
-
|
|
85
|
-
expect(result).toBeUndefined();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('should generate fallback UUID when session manager throws', async () => {
|
|
89
|
-
const { addSessionIdToOpenRouterRequest } = await import('../index.js');
|
|
90
|
-
|
|
91
|
-
const mockCtx = createSessionCtx(THROW_SESSION_ID);
|
|
92
|
-
const mockEvent = createOpenRouterRequest({
|
|
93
|
-
payload: {
|
|
94
|
-
model: 'openrouter/model',
|
|
95
|
-
messages: [],
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
|
|
100
|
-
|
|
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
|
-
);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('should fail open when payload getter throws', async () => {
|
|
108
|
-
const { addSessionIdToOpenRouterRequest } = await import('../index.js');
|
|
109
|
-
|
|
110
|
-
const mockCtx = createSessionCtx('my-session');
|
|
111
|
-
const mockEvent = {
|
|
112
|
-
provider: 'openrouter',
|
|
113
|
-
get payload() {
|
|
114
|
-
throw new Error('Payload getter error');
|
|
115
|
-
},
|
|
116
|
-
};
|
|
12
|
+
vi.doMock('../hooks.js', () => ({
|
|
13
|
+
addSessionIdToOpenRouterRequest,
|
|
14
|
+
getCurrentSessionId,
|
|
15
|
+
initializeSessionState: vi.fn(),
|
|
16
|
+
loadStartupCacheState: vi.fn(),
|
|
17
|
+
installOpenRouterHooks: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
vi.doMock('../commands.js', () => ({
|
|
20
|
+
registerOpenRouterCommands: vi.fn(),
|
|
21
|
+
}));
|
|
117
22
|
|
|
118
|
-
const
|
|
119
|
-
expect(result).toBeUndefined();
|
|
120
|
-
});
|
|
121
|
-
});
|
|
23
|
+
const index = await import('../index.js');
|
|
122
24
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
vi.resetModules();
|
|
25
|
+
expect(index.addSessionIdToOpenRouterRequest).toBe(addSessionIdToOpenRouterRequest);
|
|
26
|
+
expect(index.getCurrentSessionId).toBe(getCurrentSessionId);
|
|
126
27
|
});
|
|
127
28
|
|
|
128
29
|
it('composes startup cache loading, hook installation, and command registration', async () => {
|
|
@@ -121,11 +121,12 @@ describe('writeLocalUsage', () => {
|
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
it('appends multiple events to same daily file', async () => {
|
|
124
|
+
const usageDate = addUtcDays(getCurrentUtcDate(), -1);
|
|
124
125
|
const event1: LocalUsageEvent = {
|
|
125
126
|
id: 'test-1',
|
|
126
127
|
generationId: 'gen-1',
|
|
127
128
|
sessionId: 'session-1',
|
|
128
|
-
completedAt:
|
|
129
|
+
completedAt: `${usageDate}T10:00:00.000Z`,
|
|
129
130
|
cost: 0.001,
|
|
130
131
|
};
|
|
131
132
|
|
|
@@ -133,14 +134,14 @@ describe('writeLocalUsage', () => {
|
|
|
133
134
|
id: 'test-2',
|
|
134
135
|
generationId: 'gen-2',
|
|
135
136
|
sessionId: 'session-2',
|
|
136
|
-
completedAt:
|
|
137
|
+
completedAt: `${usageDate}T15:00:00.000Z`,
|
|
137
138
|
cost: 0.002,
|
|
138
139
|
};
|
|
139
140
|
|
|
140
141
|
await writeLocalUsage(event1);
|
|
141
142
|
await writeLocalUsage(event2);
|
|
142
143
|
|
|
143
|
-
const filePath = path.join(testDir,
|
|
144
|
+
const filePath = path.join(testDir, `${usageDate}.jsonl`);
|
|
144
145
|
const content = await fs.readFile(filePath, 'utf8');
|
|
145
146
|
const lines = content.trim().split('\n');
|
|
146
147
|
|
|
@@ -150,11 +151,13 @@ describe('writeLocalUsage', () => {
|
|
|
150
151
|
});
|
|
151
152
|
|
|
152
153
|
it('writes to different files for different UTC dates', async () => {
|
|
154
|
+
const firstDate = addUtcDays(getCurrentUtcDate(), -2);
|
|
155
|
+
const secondDate = addUtcDays(firstDate, 1);
|
|
153
156
|
const event1: LocalUsageEvent = {
|
|
154
157
|
id: 'test-1',
|
|
155
158
|
generationId: 'gen-1',
|
|
156
159
|
sessionId: 'session-1',
|
|
157
|
-
completedAt:
|
|
160
|
+
completedAt: `${firstDate}T23:59:59.999Z`,
|
|
158
161
|
cost: 0.001,
|
|
159
162
|
};
|
|
160
163
|
|
|
@@ -162,15 +165,15 @@ describe('writeLocalUsage', () => {
|
|
|
162
165
|
id: 'test-2',
|
|
163
166
|
generationId: 'gen-2',
|
|
164
167
|
sessionId: 'session-2',
|
|
165
|
-
completedAt:
|
|
168
|
+
completedAt: `${secondDate}T00:00:00.000Z`,
|
|
166
169
|
cost: 0.002,
|
|
167
170
|
};
|
|
168
171
|
|
|
169
172
|
await writeLocalUsage(event1);
|
|
170
173
|
await writeLocalUsage(event2);
|
|
171
174
|
|
|
172
|
-
const file1 = await fs.readFile(path.join(testDir,
|
|
173
|
-
const file2 = await fs.readFile(path.join(testDir,
|
|
175
|
+
const file1 = await fs.readFile(path.join(testDir, `${firstDate}.jsonl`), 'utf8');
|
|
176
|
+
const file2 = await fs.readFile(path.join(testDir, `${secondDate}.jsonl`), 'utf8');
|
|
174
177
|
|
|
175
178
|
expect(JSON.parse(file1.trim())).toEqual(event1);
|
|
176
179
|
expect(JSON.parse(file2.trim())).toEqual(event2);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SettingsManager } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
export type OpenRouterConfig = {
|
|
4
|
+
statusEnabled: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_OPENROUTER_CONFIG: OpenRouterConfig = {
|
|
8
|
+
statusEnabled: true,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getOpenRouterSettings(settings: unknown): unknown {
|
|
16
|
+
return isRecord(settings) ? settings['pi-openrouter'] : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getStatusEnabled(rawConfig: unknown): boolean | undefined {
|
|
20
|
+
if (!isRecord(rawConfig)) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return typeof rawConfig['statusEnabled'] === 'boolean' ? rawConfig['statusEnabled'] : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadOpenRouterConfig(cwd: string, projectTrusted = true): OpenRouterConfig {
|
|
28
|
+
const settingsManager = SettingsManager.create(cwd);
|
|
29
|
+
const globalStatusEnabled = getStatusEnabled(
|
|
30
|
+
getOpenRouterSettings(settingsManager.getGlobalSettings()),
|
|
31
|
+
);
|
|
32
|
+
const projectStatusEnabled = projectTrusted
|
|
33
|
+
? getStatusEnabled(getOpenRouterSettings(settingsManager.getProjectSettings()))
|
|
34
|
+
: undefined;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
statusEnabled:
|
|
38
|
+
projectStatusEnabled ?? globalStatusEnabled ?? DEFAULT_OPENROUTER_CONFIG.statusEnabled,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isStatusEnabled(cwd: string, projectTrusted = true): boolean {
|
|
43
|
+
return loadOpenRouterConfig(cwd, projectTrusted).statusEnabled;
|
|
44
|
+
}
|
|
@@ -16,12 +16,15 @@ import {
|
|
|
16
16
|
setActiveCatalogState,
|
|
17
17
|
} from './models/sync.js';
|
|
18
18
|
import { loadOpenRouterStatusBar } from './status-bar.js';
|
|
19
|
+
import { isStatusEnabled } from './config.js';
|
|
19
20
|
|
|
20
21
|
let sessionState: SessionState | null = null;
|
|
21
22
|
let sessionTrackingInstalled = false;
|
|
22
23
|
let openRouterStatusRolloverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
24
|
|
|
24
|
-
type StatusContext = Pick<ExtensionContext, 'hasUI' | 'ui'
|
|
25
|
+
type StatusContext = Pick<ExtensionContext, 'cwd' | 'hasUI' | 'ui'> & {
|
|
26
|
+
isProjectTrusted?: () => boolean;
|
|
27
|
+
};
|
|
25
28
|
|
|
26
29
|
export interface StartupCacheState {
|
|
27
30
|
info?: {
|
|
@@ -213,9 +216,30 @@ function captureLocalUsage(event: unknown, ctx: ExtensionContext): void {
|
|
|
213
216
|
}
|
|
214
217
|
}
|
|
215
218
|
|
|
219
|
+
function getProjectTrusted(ctx: StatusContext): boolean {
|
|
220
|
+
try {
|
|
221
|
+
return ctx.isProjectTrusted?.() === true;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isOpenRouterStatusEnabled(ctx: StatusContext): boolean {
|
|
228
|
+
try {
|
|
229
|
+
return isStatusEnabled(ctx.cwd, getProjectTrusted(ctx));
|
|
230
|
+
} catch {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
216
235
|
async function refreshOpenRouterUsageStatus(ctx: StatusContext): Promise<void> {
|
|
217
236
|
if (!ctx.hasUI) return;
|
|
218
237
|
|
|
238
|
+
if (!isOpenRouterStatusEnabled(ctx)) {
|
|
239
|
+
ctx.ui.setStatus('openrouter', undefined);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
219
243
|
try {
|
|
220
244
|
const statusResult = await loadOpenRouterStatusBar();
|
|
221
245
|
|
|
@@ -258,7 +282,7 @@ function getMillisecondsUntilNextUtcMidnight(now: Date = new Date()): number {
|
|
|
258
282
|
function scheduleOpenRouterStatusRollover(ctx: StatusContext): void {
|
|
259
283
|
clearOpenRouterStatusRolloverTimer();
|
|
260
284
|
|
|
261
|
-
if (!ctx.hasUI) {
|
|
285
|
+
if (!ctx.hasUI || !isOpenRouterStatusEnabled(ctx)) {
|
|
262
286
|
return;
|
|
263
287
|
}
|
|
264
288
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robhowley/pi-openrouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Live OpenRouter spend/account TUI overlays, user-scoped or free-only model sync, api key management, and session tagging for Pi.",
|
|
6
6
|
"license": "MIT",
|