@robhowley/pi-openrouter 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -13
- package/extensions/openrouter/__tests__/format.test.ts +6 -4
- package/extensions/openrouter/__tests__/session.test.ts +321 -1
- package/extensions/openrouter/account-overlay.ts +10 -6
- package/extensions/openrouter/index.ts +58 -12
- package/extensions/openrouter/overlay.ts +11 -9
- package/extensions/openrouter/session.ts +20 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
|
|
|
21
21
|
|
|
22
22
|
## Usage
|
|
23
23
|
|
|
24
|
-
Type `/openrouter
|
|
24
|
+
Type `/openrouter usage` in Pi to open the usage overlay.
|
|
25
25
|
|
|
26
26
|
The overlay shows:
|
|
27
27
|
- **Month spend** vs cap with percentage
|
|
@@ -37,7 +37,7 @@ The extension refreshes data in the background every 30 seconds (with exponentia
|
|
|
37
37
|
|
|
38
38
|
## Account health
|
|
39
39
|
|
|
40
|
-
Type `/openrouter
|
|
40
|
+
Type `/openrouter account` in Pi to open the account health overlay.
|
|
41
41
|
|
|
42
42
|
The overlay shows:
|
|
43
43
|
|
|
@@ -56,25 +56,17 @@ Select a key from the list to inspect its limit, usage, reset cadence, and BYOK
|
|
|
56
56
|
|
|
57
57
|
## Session tracking
|
|
58
58
|
|
|
59
|
-
`pi-openrouter` automatically tags OpenRouter requests with `session_id`
|
|
59
|
+
`pi-openrouter` automatically tags OpenRouter requests with a `session_id` derived from the Pi session ID.
|
|
60
60
|
|
|
61
|
-
View the
|
|
61
|
+
View the OpenRouter session tag with:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
/session
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
The session can be tracked in OpenRouter's logs under the following ID:
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
/openrouter-session
|
|
64
|
+
/openrouter session
|
|
71
65
|
|
|
72
66
|
# OpenRouter session_id
|
|
73
67
|
pi:[uuid]
|
|
74
68
|
```
|
|
75
69
|
|
|
76
|
-
This enables session-level tracking in the OpenRouter Logs → Sessions page.
|
|
77
|
-
|
|
78
70
|
## License
|
|
79
71
|
|
|
80
72
|
MIT
|
|
@@ -43,8 +43,10 @@ describe('aggregateUsage', () => {
|
|
|
43
43
|
totalCredits: 100,
|
|
44
44
|
};
|
|
45
45
|
// Use a date that's within the last 7 days of when the test runs.
|
|
46
|
-
//
|
|
47
|
-
const
|
|
46
|
+
// Get today's date in UTC and subtract a few days to ensure it's in the week window.
|
|
47
|
+
const now = new Date();
|
|
48
|
+
const testDate = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3 days ago
|
|
49
|
+
const date = `${testDate.getUTCFullYear()}-${String(testDate.getUTCMonth() + 1).padStart(2, '0')}-${String(testDate.getUTCDate()).padStart(2, '0')}`;
|
|
48
50
|
const analytics: ActivityItem[] = [
|
|
49
51
|
{
|
|
50
52
|
date: date,
|
|
@@ -77,9 +79,9 @@ describe('aggregateUsage', () => {
|
|
|
77
79
|
const result = aggregateUsage(credits, analytics);
|
|
78
80
|
|
|
79
81
|
expect(result.month).toBe(38.42);
|
|
80
|
-
// Data from
|
|
82
|
+
// Data from the test date should be in the week (7 days ago window)
|
|
81
83
|
expect(result.week).toBeGreaterThan(0);
|
|
82
|
-
// Today's data might not be from
|
|
84
|
+
// Today's data might not be from the test date due to timezone, so just check month
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
it('should calculate burn rate correctly', () => {
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { isOpenRouterRequest, formatSessionId } from '../session.js';
|
|
3
3
|
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// Parameterized Test Types
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
interface DetectionTestCase {
|
|
9
|
+
name: string;
|
|
10
|
+
event: Record<string, unknown>;
|
|
11
|
+
ctx?: Record<string, unknown>;
|
|
12
|
+
expected: boolean;
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
// =============================================================================
|
|
5
17
|
// Session ID Formatting Tests
|
|
6
18
|
// =============================================================================
|
|
@@ -68,7 +80,8 @@ describe('isOpenRouterRequest', () => {
|
|
|
68
80
|
});
|
|
69
81
|
|
|
70
82
|
it('does not detect non-ZDR provider', () => {
|
|
71
|
-
|
|
83
|
+
// Use provider name that won't match Method 5 (not "openrouter")
|
|
84
|
+
const event = createEvent({ model: 'qwen/qwen3-coder-next', provider: 'anthropic' });
|
|
72
85
|
expect(isOpenRouterRequest(event, {})).toBe(false);
|
|
73
86
|
});
|
|
74
87
|
|
|
@@ -98,4 +111,311 @@ describe('isOpenRouterRequest', () => {
|
|
|
98
111
|
const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
|
|
99
112
|
expect(isOpenRouterRequest(event, ctx)).toBe(true);
|
|
100
113
|
});
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// Parameterized Tests - All Detection Methods
|
|
117
|
+
// =============================================================================
|
|
118
|
+
|
|
119
|
+
const detectionCases: DetectionTestCase[] = [
|
|
120
|
+
// Method 1: Model string prefix
|
|
121
|
+
{
|
|
122
|
+
name: 'method1: openrouter/ prefix',
|
|
123
|
+
event: { payload: { model: 'openrouter/anthropic/claude-3' } },
|
|
124
|
+
ctx: {},
|
|
125
|
+
expected: true,
|
|
126
|
+
description: 'Model with openrouter/ prefix should be detected',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'method1: no prefix - should fail',
|
|
130
|
+
event: { payload: { model: 'anthropic/claude-3' } },
|
|
131
|
+
ctx: {},
|
|
132
|
+
expected: false,
|
|
133
|
+
description: 'Model without openrouter/ prefix should not be detected by method 1',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'method1: similar but not prefix',
|
|
137
|
+
event: { payload: { model: 'my-openrouter-model' } },
|
|
138
|
+
ctx: {},
|
|
139
|
+
expected: false,
|
|
140
|
+
description: 'Model containing openrouter but not as prefix should not match',
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Method 2: baseUrl in context.model
|
|
144
|
+
{
|
|
145
|
+
name: 'method2: baseUrl contains openrouter.ai',
|
|
146
|
+
event: { payload: { model: 'qwen/coder' } },
|
|
147
|
+
ctx: { model: { baseUrl: 'https://openrouter.ai/api/v1' } },
|
|
148
|
+
expected: true,
|
|
149
|
+
description: 'Context with openrouter.ai baseUrl should be detected',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'method2: different baseUrl',
|
|
153
|
+
event: { payload: { model: 'claude-3' } },
|
|
154
|
+
ctx: { model: { baseUrl: 'https://api.anthropic.com' } },
|
|
155
|
+
expected: false,
|
|
156
|
+
description: 'Non-OpenRouter baseUrl should not be detected',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'method2: missing baseUrl',
|
|
160
|
+
event: { payload: { model: 'claude-3' } },
|
|
161
|
+
ctx: { model: {} },
|
|
162
|
+
expected: false,
|
|
163
|
+
description: 'Missing baseUrl should not be detected by method 2',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: 'method2: no model in context',
|
|
167
|
+
event: { payload: { model: 'claude-3' } },
|
|
168
|
+
ctx: {},
|
|
169
|
+
expected: false,
|
|
170
|
+
description: 'Empty context should not crash method 2',
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
// Method 3: ZDR provider
|
|
174
|
+
{
|
|
175
|
+
name: 'method3: ZDR provider flag',
|
|
176
|
+
event: { payload: { model: 'qwen/coder' }, provider: { zdr: true } },
|
|
177
|
+
ctx: {},
|
|
178
|
+
expected: true,
|
|
179
|
+
description: 'Provider with zdr: true should be detected',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'method3: non-ZDR provider',
|
|
183
|
+
event: { payload: { model: 'qwen/coder' }, provider: { zdr: false } },
|
|
184
|
+
ctx: {},
|
|
185
|
+
expected: false,
|
|
186
|
+
description: 'Provider with zdr: false should not be detected',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'method3: no provider object',
|
|
190
|
+
event: { payload: { model: 'qwen/coder' } },
|
|
191
|
+
ctx: {},
|
|
192
|
+
expected: false,
|
|
193
|
+
description: 'Missing provider should not be detected by method 3',
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// Method 4: URL check
|
|
197
|
+
{
|
|
198
|
+
name: 'method4: url contains openrouter.ai',
|
|
199
|
+
event: { payload: { model: 'qwen/coder' }, url: 'https://openrouter.ai/api/v1/chat' },
|
|
200
|
+
ctx: {},
|
|
201
|
+
expected: true,
|
|
202
|
+
description: 'URL containing openrouter.ai should be detected',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'method4: endpoint property (alternative to url)',
|
|
206
|
+
event: { payload: { model: 'qwen/coder' }, endpoint: 'https://openrouter.ai/api/v1/chat' },
|
|
207
|
+
ctx: {},
|
|
208
|
+
expected: true,
|
|
209
|
+
description: 'Endpoint property should also be checked (fallback to url)',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'method4: non-OpenRouter url',
|
|
213
|
+
event: { payload: { model: 'qwen/coder' }, url: 'https://api.anthropic.com/v1/messages' },
|
|
214
|
+
ctx: {},
|
|
215
|
+
expected: false,
|
|
216
|
+
description: 'Non-OpenRouter URL should not be detected',
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'method4: url with openrouter.ai in path (not just domain)',
|
|
220
|
+
event: {
|
|
221
|
+
payload: { model: 'qwen/coder' },
|
|
222
|
+
url: 'https://proxy.example.com/v1/openrouter.ai/endpoint',
|
|
223
|
+
},
|
|
224
|
+
ctx: {},
|
|
225
|
+
expected: true,
|
|
226
|
+
description: 'URL containing openrouter.ai anywhere in string should match',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'method4: url without openrouter.ai string',
|
|
230
|
+
event: { payload: { model: 'qwen/coder' }, url: 'https://example.com/api' },
|
|
231
|
+
ctx: {},
|
|
232
|
+
expected: false,
|
|
233
|
+
description: 'URL without openrouter.ai should not be detected',
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
// Method 5: Provider name check (Pi coding agent uses "openrouter" provider)
|
|
237
|
+
{
|
|
238
|
+
name: 'method5: provider as string "openrouter" at event level',
|
|
239
|
+
event: { payload: { model: 'claude-3' }, provider: 'openrouter' },
|
|
240
|
+
ctx: {},
|
|
241
|
+
expected: true,
|
|
242
|
+
description: 'Provider name "openrouter" as string should be detected',
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: 'method5: provider as string "openrouter" in payload',
|
|
246
|
+
event: { payload: { model: 'claude-3', provider: 'openrouter' } },
|
|
247
|
+
ctx: {},
|
|
248
|
+
expected: true,
|
|
249
|
+
description: 'Provider name "openrouter" in payload should be detected',
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'method5: provider object with name "openrouter"',
|
|
253
|
+
event: { payload: { model: 'claude-3' }, provider: { name: 'openrouter' } },
|
|
254
|
+
ctx: {},
|
|
255
|
+
expected: true,
|
|
256
|
+
description: 'Provider object with name "openrouter" should be detected',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'method5: provider in payload with object name',
|
|
260
|
+
event: { payload: { model: 'claude-3', provider: { name: 'openrouter' } } },
|
|
261
|
+
ctx: {},
|
|
262
|
+
expected: true,
|
|
263
|
+
description: 'Provider object in payload with name "openrouter" should be detected',
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: 'method5: different provider name',
|
|
267
|
+
event: { payload: { model: 'claude-3', provider: 'anthropic' } },
|
|
268
|
+
ctx: {},
|
|
269
|
+
expected: false,
|
|
270
|
+
description: 'Different provider name should not be detected',
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: 'method5: similar but not exact provider name',
|
|
274
|
+
event: { payload: { model: 'claude-3', provider: 'openrouter-proxy' } },
|
|
275
|
+
ctx: {},
|
|
276
|
+
expected: false,
|
|
277
|
+
description: 'Provider name containing but not exactly "openrouter" should not match',
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: 'method4: url with openrouter.ai in path (not just domain)',
|
|
281
|
+
event: {
|
|
282
|
+
payload: { model: 'qwen/coder' },
|
|
283
|
+
url: 'https://proxy.example.com/v1/openrouter.ai/endpoint',
|
|
284
|
+
},
|
|
285
|
+
ctx: {},
|
|
286
|
+
expected: true,
|
|
287
|
+
description: 'URL containing openrouter.ai anywhere in string should match',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'method4: url without openrouter.ai string',
|
|
291
|
+
event: { payload: { model: 'qwen/coder' }, url: 'https://example.com/api' },
|
|
292
|
+
ctx: {},
|
|
293
|
+
expected: false,
|
|
294
|
+
description: 'URL without openrouter.ai should not be detected',
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
// Edge cases - missing all detection methods
|
|
298
|
+
{
|
|
299
|
+
name: 'edge: empty event',
|
|
300
|
+
event: {},
|
|
301
|
+
ctx: {},
|
|
302
|
+
expected: false,
|
|
303
|
+
description: 'Empty event should not be detected',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'edge: payload only with no model',
|
|
307
|
+
event: { payload: { messages: [] } },
|
|
308
|
+
ctx: {},
|
|
309
|
+
expected: false,
|
|
310
|
+
description: 'Event with payload but no model should not be detected',
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: 'edge: null model',
|
|
314
|
+
event: { payload: { model: null } },
|
|
315
|
+
ctx: {},
|
|
316
|
+
expected: false,
|
|
317
|
+
description: 'Null model should be handled as string "null" and not match',
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'edge: undefined model',
|
|
321
|
+
event: { payload: {} },
|
|
322
|
+
ctx: {},
|
|
323
|
+
expected: false,
|
|
324
|
+
description: 'Undefined model should not crash and not be detected',
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
// turn_end specific - simulating real turn_end event structure
|
|
328
|
+
{
|
|
329
|
+
name: 'turn_end style: url at event level with resolved model',
|
|
330
|
+
event: {
|
|
331
|
+
type: 'turn_end',
|
|
332
|
+
payload: { model: 'qwen/qwen3-coder-next', responseId: 'gen-123' },
|
|
333
|
+
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
334
|
+
message: { model: 'qwen/qwen3-coder-next', usage: {} },
|
|
335
|
+
},
|
|
336
|
+
ctx: {},
|
|
337
|
+
expected: true,
|
|
338
|
+
description: 'turn_end event structure with URL should be detected',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: 'turn_end style: no url but endpoint',
|
|
342
|
+
event: {
|
|
343
|
+
type: 'turn_end',
|
|
344
|
+
payload: { model: 'moonshotai/kimi-k2.5' },
|
|
345
|
+
endpoint: 'https://openrouter.ai/api/v1/chat/completions',
|
|
346
|
+
},
|
|
347
|
+
ctx: {},
|
|
348
|
+
expected: true,
|
|
349
|
+
description: 'turn_end with endpoint instead of url should be detected',
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: 'turn_end style: no url or endpoint (would fail)',
|
|
353
|
+
event: {
|
|
354
|
+
type: 'turn_end',
|
|
355
|
+
payload: { model: 'moonshotai/kimi-k2.5' },
|
|
356
|
+
},
|
|
357
|
+
ctx: {},
|
|
358
|
+
expected: false,
|
|
359
|
+
description: 'turn_end without URL/endpoint and without openrouter/ prefix would fail',
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
// Multiple methods at once
|
|
363
|
+
{
|
|
364
|
+
name: 'multi: all methods satisfied',
|
|
365
|
+
event: {
|
|
366
|
+
payload: { model: 'openrouter/anthropic/claude-3' },
|
|
367
|
+
url: 'https://openrouter.ai/api/v1/chat',
|
|
368
|
+
provider: { zdr: true },
|
|
369
|
+
},
|
|
370
|
+
ctx: { model: { baseUrl: 'https://openrouter.ai/api/v1' } },
|
|
371
|
+
expected: true,
|
|
372
|
+
description: 'All detection methods satisfied should return true',
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: 'multi: only method 4 (url) satisfied',
|
|
376
|
+
event: {
|
|
377
|
+
payload: { model: 'any-model-name' },
|
|
378
|
+
url: 'https://openrouter.ai/api/v1',
|
|
379
|
+
},
|
|
380
|
+
ctx: {},
|
|
381
|
+
expected: true,
|
|
382
|
+
description: 'Only URL method satisfied should be sufficient',
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
name: 'multi: only method 2 (baseUrl) satisfied',
|
|
386
|
+
event: { payload: { model: 'any-model' } },
|
|
387
|
+
ctx: { model: { baseUrl: 'https://openrouter.ai/api/v1' } },
|
|
388
|
+
expected: true,
|
|
389
|
+
description: 'Only baseUrl method satisfied should be sufficient',
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: 'multi: only method 3 (zdr) satisfied',
|
|
393
|
+
event: {
|
|
394
|
+
payload: { model: 'any-model' },
|
|
395
|
+
provider: { zdr: true },
|
|
396
|
+
},
|
|
397
|
+
ctx: {},
|
|
398
|
+
expected: true,
|
|
399
|
+
description: 'Only ZDR method satisfied should be sufficient',
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
// Cache mismatch - model appears openrouter but URL doesn't (edge case)
|
|
403
|
+
{
|
|
404
|
+
name: 'edge: model says openrouter but URL says different',
|
|
405
|
+
event: {
|
|
406
|
+
payload: { model: 'openrouter/anthropic/claude-3' },
|
|
407
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
408
|
+
},
|
|
409
|
+
expected: true,
|
|
410
|
+
description: 'Model prefix takes precedence - should still detect as OpenRouter',
|
|
411
|
+
},
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
// Run all parameterized tests
|
|
415
|
+
for (const testCase of detectionCases) {
|
|
416
|
+
it(testCase.name, () => {
|
|
417
|
+
const result = isOpenRouterRequest(testCase.event as any, testCase.ctx as any);
|
|
418
|
+
expect(result).toBe(testCase.expected);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
101
421
|
});
|
|
@@ -173,15 +173,21 @@ export class AccountOverlayComponent {
|
|
|
173
173
|
return Math.max(MIN_WIDTH, this.keyInfo && this.keyInfo.length > 0 ? 55 : 50);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/** Get the header row for the account overlay */
|
|
177
|
+
private getAccountHeaderRow(): string {
|
|
178
|
+
return row(
|
|
179
|
+
this.theme.fg('accent', this.theme.bold(' ◈ OpenRouter Account · /openrouter account')),
|
|
180
|
+
this.width,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
176
184
|
private buildLines(): string[] {
|
|
177
185
|
const th = this.theme;
|
|
178
186
|
const lines: string[] = [];
|
|
179
187
|
|
|
180
188
|
if (this.error) {
|
|
181
189
|
lines.push(boxTop(this.width));
|
|
182
|
-
lines.push(
|
|
183
|
-
row(th.fg('accent', th.bold(' ◈ OpenRouter Account · /openrouter-account')), this.width),
|
|
184
|
-
);
|
|
190
|
+
lines.push(this.getAccountHeaderRow());
|
|
185
191
|
lines.push(emptyRow(this.width));
|
|
186
192
|
lines.push(row(th.fg('error', this.error), this.width));
|
|
187
193
|
lines.push(boxBottom(this.width));
|
|
@@ -190,9 +196,7 @@ export class AccountOverlayComponent {
|
|
|
190
196
|
}
|
|
191
197
|
|
|
192
198
|
lines.push(boxTop(this.width));
|
|
193
|
-
lines.push(
|
|
194
|
-
row(th.fg('accent', th.bold(' ◈ OpenRouter Account · /openrouter-account')), this.width),
|
|
195
|
-
);
|
|
199
|
+
lines.push(this.getAccountHeaderRow());
|
|
196
200
|
lines.push(emptyRow(this.width));
|
|
197
201
|
|
|
198
202
|
// Total spend line (sum of all key spends)
|
|
@@ -95,10 +95,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
95
|
if (!message) return;
|
|
96
96
|
|
|
97
97
|
// Check if this is an OpenRouter request based on the message content/model
|
|
98
|
+
// Include url/endpoint from turnEvent so isOpenRouterRequest can check them
|
|
98
99
|
const isOpenRouter = isOpenRouterRequest(
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
{
|
|
101
|
+
type: 'before_provider_request',
|
|
102
|
+
payload: message,
|
|
103
|
+
url: turnEvent['url'],
|
|
104
|
+
endpoint: turnEvent['endpoint'],
|
|
105
|
+
} as unknown as Parameters<typeof isOpenRouterRequest>[0],
|
|
102
106
|
ctx,
|
|
103
107
|
);
|
|
104
108
|
if (!isOpenRouter) return;
|
|
@@ -130,18 +134,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
130
134
|
// Calculate total cost from usage.cost.total
|
|
131
135
|
const totalCost = usage.cost?.total;
|
|
132
136
|
|
|
133
|
-
const localEvent = {
|
|
137
|
+
const localEvent: LocalUsageEvent = {
|
|
134
138
|
id: crypto.randomUUID(),
|
|
135
|
-
generationId: message['responseId'],
|
|
139
|
+
generationId: String(message['responseId'] ?? ''),
|
|
136
140
|
sessionId: getCurrentSessionId(ctx),
|
|
137
141
|
completedAt: new Date().toISOString(),
|
|
138
|
-
model: modelToLog,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
model: modelToLog ?? 'unknown',
|
|
143
|
+
requests: 1,
|
|
144
|
+
promptTokens: usage.input ?? 0,
|
|
145
|
+
completionTokens: usage.output ?? 0,
|
|
146
|
+
reasoningTokens: 0,
|
|
147
|
+
cacheReadTokens: usage.cacheRead ?? 0,
|
|
148
|
+
cacheWriteTokens: usage.cacheWrite ?? 0,
|
|
149
|
+
cost: totalCost ?? 0,
|
|
150
|
+
};
|
|
145
151
|
|
|
146
152
|
// Write to local JSONL - fail open (don't throw)
|
|
147
153
|
writeLocalUsage(localEvent).catch(() => {});
|
|
@@ -180,6 +186,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
180
186
|
await showAccountOverlay(ctx);
|
|
181
187
|
},
|
|
182
188
|
});
|
|
189
|
+
|
|
190
|
+
// Single entry point with subcommands: /openrouter [usage|account|session]
|
|
191
|
+
pi.registerCommand('openrouter', {
|
|
192
|
+
description: 'OpenRouter commands: usage, account, session',
|
|
193
|
+
getArgumentCompletions: (prefix: string) => {
|
|
194
|
+
const subcommands = ['usage', 'account', 'session'];
|
|
195
|
+
const items = subcommands
|
|
196
|
+
.filter((s) => s.startsWith(prefix))
|
|
197
|
+
.map((s) => ({ value: s, label: s }));
|
|
198
|
+
return items.length > 0 ? items : null;
|
|
199
|
+
},
|
|
200
|
+
handler: async (args, ctx) => {
|
|
201
|
+
const subcommand = args.trim().split(/\s+/)[0] || '';
|
|
202
|
+
|
|
203
|
+
switch (subcommand) {
|
|
204
|
+
case 'usage': {
|
|
205
|
+
startBackgroundRefresh();
|
|
206
|
+
await showUsageOverlay(ctx, undefined);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case 'account': {
|
|
210
|
+
await showAccountOverlay(ctx);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'session': {
|
|
214
|
+
ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
default: {
|
|
218
|
+
const available = ['usage', 'account', 'session'];
|
|
219
|
+
const message =
|
|
220
|
+
available.length > 0
|
|
221
|
+
? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
|
|
222
|
+
: 'No subcommands available';
|
|
223
|
+
ctx.ui.notify(`OpenRouter subcommands\n${message}`, 'error');
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
});
|
|
183
229
|
}
|
|
184
230
|
|
|
185
231
|
async function showAccountOverlay(ctx: ExtensionContext) {
|
|
@@ -118,6 +118,14 @@ export class UsageOverlayComponent {
|
|
|
118
118
|
return Math.max(MIN_WIDTH, innerWidth + 4) + 6; // +4 for borders, +6 for visual padding (3 on each side)
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** Get the header row for the usage overlay */
|
|
122
|
+
private getUsageHeaderRow(): string {
|
|
123
|
+
return row(
|
|
124
|
+
this.theme.fg('accent', this.theme.bold(' ◈ OpenRouter Usage · /openrouter usage')),
|
|
125
|
+
this.width,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
121
129
|
private buildLines(
|
|
122
130
|
summary: UsageSummary | null,
|
|
123
131
|
error: string | null,
|
|
@@ -128,9 +136,7 @@ export class UsageOverlayComponent {
|
|
|
128
136
|
|
|
129
137
|
if (error) {
|
|
130
138
|
lines.push(boxTop(this.width));
|
|
131
|
-
lines.push(
|
|
132
|
-
row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
|
|
133
|
-
);
|
|
139
|
+
lines.push(this.getUsageHeaderRow());
|
|
134
140
|
lines.push(emptyRow(this.width));
|
|
135
141
|
lines.push(row(th.fg('error', error), this.width));
|
|
136
142
|
if (cachedMinutesAgo !== null) {
|
|
@@ -145,9 +151,7 @@ export class UsageOverlayComponent {
|
|
|
145
151
|
|
|
146
152
|
if (!summary) {
|
|
147
153
|
lines.push(boxTop(this.width));
|
|
148
|
-
lines.push(
|
|
149
|
-
row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
|
|
150
|
-
);
|
|
154
|
+
lines.push(this.getUsageHeaderRow());
|
|
151
155
|
lines.push(emptyRow(this.width));
|
|
152
156
|
lines.push(row(th.fg('dim', 'No usage data available.'), this.width));
|
|
153
157
|
lines.push(boxBottom(this.width));
|
|
@@ -157,9 +161,7 @@ export class UsageOverlayComponent {
|
|
|
157
161
|
|
|
158
162
|
// Summary view (subcommand views TODO)
|
|
159
163
|
lines.push(boxTop(this.width));
|
|
160
|
-
lines.push(
|
|
161
|
-
row(th.fg('accent', th.bold(' ◈ OpenRouter Usage · /openrouter-usage')), this.width),
|
|
162
|
-
);
|
|
164
|
+
lines.push(this.getUsageHeaderRow());
|
|
163
165
|
lines.push(emptyRow(this.width));
|
|
164
166
|
|
|
165
167
|
// Month row: amount stays with label, cap percentage right-aligned
|
|
@@ -25,15 +25,30 @@ export function formatSessionId(sessionId: string): string {
|
|
|
25
25
|
|
|
26
26
|
export function isOpenRouterRequest(event: BeforeProviderRequestEvent, _ctx: unknown): boolean {
|
|
27
27
|
const ev = event as unknown as Record<string, unknown>;
|
|
28
|
-
|
|
29
|
-
// Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
|
|
30
28
|
const payload = ev['payload'] as Record<string, unknown> | undefined;
|
|
29
|
+
|
|
30
|
+
// Method 1: Check if provider is explicitly "openrouter" (Pi coding agent first-class)
|
|
31
|
+
// Provider could be in event.provider, event.payload.provider, as string or object with name
|
|
32
|
+
const eventProvider = ev['provider'];
|
|
33
|
+
const payloadProvider = payload?.['provider'];
|
|
34
|
+
const providerName =
|
|
35
|
+
typeof eventProvider === 'string'
|
|
36
|
+
? eventProvider
|
|
37
|
+
: typeof payloadProvider === 'string'
|
|
38
|
+
? payloadProvider
|
|
39
|
+
: ((eventProvider as Record<string, unknown> | undefined)?.['name'] ??
|
|
40
|
+
(payloadProvider as Record<string, unknown> | undefined)?.['name']);
|
|
41
|
+
if (providerName === 'openrouter') {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Method 2: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
|
|
31
46
|
const model = String(payload?.['model'] ?? '');
|
|
32
47
|
if (model.includes('openrouter/')) {
|
|
33
48
|
return true;
|
|
34
49
|
}
|
|
35
50
|
|
|
36
|
-
// Method
|
|
51
|
+
// Method 3: Check baseUrl from context.model
|
|
37
52
|
// OpenRouter models have baseUrl starting with https://openrouter.ai/api/v1
|
|
38
53
|
const context = _ctx as Record<string, unknown>;
|
|
39
54
|
const ctxModel = context['model'] as Record<string, unknown> | undefined;
|
|
@@ -42,13 +57,13 @@ export function isOpenRouterRequest(event: BeforeProviderRequestEvent, _ctx: unk
|
|
|
42
57
|
return true;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
// Method
|
|
60
|
+
// Method 4: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
|
|
46
61
|
const provider = ev['provider'] as Record<string, unknown> | undefined;
|
|
47
62
|
if (provider?.['zdr'] === true) {
|
|
48
63
|
return true;
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
// Method
|
|
66
|
+
// Method 5: Check URL (fallback for events where provider info is missing)
|
|
52
67
|
const url = String(
|
|
53
68
|
(ev['url'] as string | undefined) ?? (ev['endpoint'] as string | undefined) ?? '',
|
|
54
69
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robhowley/pi-openrouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Live OpenRouter TUI overlays for spend, credits, key limits, burn rate, model usage, and session tagging.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"url": "https://github.com/robhowley/pi-userland.git",
|
|
28
28
|
"directory": "packages/pi-openrouter"
|
|
29
29
|
},
|
|
30
|
-
"homepage": "https://
|
|
30
|
+
"homepage": "https://pi-userland.dev",
|
|
31
31
|
"scripts": {
|
|
32
32
|
"lint": "eslint extensions/",
|
|
33
33
|
"format:check": "prettier --check extensions/",
|