@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 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(overrides: { hasUI?: boolean } = {}) {
66
- return {
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('addSessionIdToOpenRouterRequest', () => {
3
+ describe('openrouter index entrypoint', () => {
5
4
  beforeEach(() => {
6
5
  vi.resetModules();
7
6
  });
8
7
 
9
- it('should add session_id to OpenRouter requests', async () => {
10
- const { addSessionIdToOpenRouterRequest } = await import('../index.js');
8
+ it('re-exports named helpers from hooks.js', async () => {
9
+ const addSessionIdToOpenRouterRequest = vi.fn();
10
+ const getCurrentSessionId = vi.fn();
11
11
 
12
- const mockCtx = createSessionCtx('stable-session-123');
13
- const mockEvent = createOpenRouterRequest();
14
-
15
- const result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
16
-
17
- expect(result).toBeDefined();
18
- expect(result?.['session_id']).toBe('pi:stable-session-123');
19
- expect(result?.['model']).toBe('openrouter/anthropic/claude-sonnet-4');
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 result = addSessionIdToOpenRouterRequest(mockEvent, mockCtx);
119
- expect(result).toBeUndefined();
120
- });
121
- });
23
+ const index = await import('../index.js');
122
24
 
123
- describe('default extension export', () => {
124
- beforeEach(() => {
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: '2026-05-22T10:00:00.000Z',
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: '2026-05-22T15:00:00.000Z',
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, '2026-05-22.jsonl');
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: '2026-05-22T23:59:59.999Z',
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: '2026-05-23T00:00:00.000Z',
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, '2026-05-22.jsonl'), 'utf8');
173
- const file2 = await fs.readFile(path.join(testDir, '2026-05-23.jsonl'), 'utf8');
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.12.1",
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",