@robhowley/pi-openrouter 0.12.0 → 0.13.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
@@ -1,6 +1,5 @@
1
1
  # pi-openrouter
2
2
 
3
- A [Pi](https://pi.dev/) extension for live OpenRouter visibility and environment sync: usage/account TUI overlays, automatic `session_id` tagging, user-scoped model catalog sync, api key management, and local model field overrides.
4
3
  A [Pi](https://pi.dev/) extension for live OpenRouter visibility and environment sync: usage/account TUI overlays, automatic session_id tagging, full or free-only model catalog sync, API key management, and local model field overrides.
5
4
 
6
5
  ## Installation
@@ -169,6 +168,20 @@ The extension logs completed OpenRouter turns to local JSONL files in `~/.pi/ope
169
168
 
170
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`.
171
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
+
172
185
  Model count and cache health remain available through `/openrouter models-status`.
173
186
 
174
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 () => {
@@ -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.0",
3
+ "version": "0.13.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",