@robhowley/pi-openrouter 0.8.2 → 0.9.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,6 @@
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, and user-scoped model catalog sync.
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, and local model field overrides.
4
4
 
5
5
  ## Installation
6
6
 
@@ -28,6 +28,9 @@ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
28
28
  /openrouter models-sync # sync user-scoped OpenRouter models into Pi
29
29
  /openrouter models-status # show model sync/cache status
30
30
  /openrouter models-status --skipped # show skipped model reasons
31
+ /openrouter model-override-set # set local model field overrides
32
+ /openrouter model-override-list # list local model field overrides
33
+ /openrouter model-override-clear # clear local model field overrides
31
34
  ```
32
35
 
33
36
  ## Model catalog sync
@@ -64,6 +67,7 @@ Run '/openrouter models-sync' to register models
64
67
  Type `/openrouter usage` in Pi to open the usage overlay.
65
68
 
66
69
  The overlay shows:
70
+
67
71
  - **Month spend** vs cap with percentage
68
72
  - **7-day spend** with burn rate projection
69
73
  - **Today's spend** from live tracked turns while Activity API data catches up
@@ -107,6 +111,50 @@ View the OpenRouter session tag with:
107
111
  pi:[uuid]
108
112
  ```
109
113
 
114
+ ## Model field overrides
115
+
116
+ Some OpenRouter models don't have complete metadata in Pi's built-in registry or the OpenRouter model catalog. You can manually configure supported `PiModelConfig` fields using scoped syntax:
117
+
118
+ ```bash
119
+ # Override thinking levels for DeepSeek V4 Pro
120
+ /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max
121
+
122
+ # Same thing with exact field names
123
+ /openrouter model-override-set deepseek/deepseek-v4-pro thinkingLevelMap.high=high thinkingLevelMap.xhigh=max
124
+
125
+ # Override context window or max tokens
126
+ /openrouter model-override-set custom/model contextWindow=128000 maxTokens=8192
127
+ ```
128
+
129
+ **Scoped field names:**
130
+
131
+ - `thinking.off`, `thinking.minimal`, `thinking.low`, `thinking.medium`, `thinking.high`, `thinking.xhigh` → map to `thinkingLevelMap.*`
132
+ - `contextWindow` → `contextWindow` (number)
133
+ - `maxTokens` → `maxTokens` (number)
134
+ - `reasoning` → `reasoning` (boolean)
135
+
136
+ Use `null` to hide a level from Pi's UI:
137
+
138
+ ```bash
139
+ /openrouter model-override-set deepseek/deepseek-v4-pro thinking.off=null
140
+ ```
141
+
142
+ List your overrides:
143
+
144
+ ```bash
145
+ /openrouter model-override-list # all models
146
+ /openrouter model-override-list --fields # available fields
147
+ /openrouter model-override-list deepseek/deepseek-v4-pro # specific model
148
+ ```
149
+
150
+ Clear overrides:
151
+
152
+ ```bash
153
+ /openrouter model-override-clear deepseek/deepseek-v4-pro
154
+ ```
155
+
156
+ Overrides are stored in `~/.pi/openrouter/model-overrides.json` and merge on top of OpenRouter catalog data and Pi's built-in registry. Run `/openrouter models-sync` after changing overrides to apply them to the registered OpenRouter model list.
157
+
110
158
  ## License
111
159
 
112
160
  MIT
@@ -132,3 +132,30 @@ export function createPiModelConfig(overrides?: Partial<PiModelConfig>): PiModel
132
132
  ...overrides,
133
133
  };
134
134
  }
135
+
136
+ // =============================================================================
137
+ // LocalUsageEvent Fixtures
138
+ // =============================================================================
139
+
140
+ import type { LocalUsageEvent } from '../types.js';
141
+
142
+ /**
143
+ * Creates LocalUsageEvent objects for testing local usage aggregation.
144
+ * Use daysAgo=0 for today, daysAgo=3 for within 7d window, etc.
145
+ */
146
+ export function createLocalEvents(
147
+ events: Array<{ daysAgo: number; cost: number; model?: string; provider?: string }>,
148
+ ): LocalUsageEvent[] {
149
+ return events.map((e, i) => ({
150
+ id: `local-${i}`,
151
+ sessionId: 'test-session',
152
+ generationId: `gen-${i}`,
153
+ completedAt: `${createTestDate(e.daysAgo)}T12:00:00.000Z`,
154
+ requests: 1,
155
+ model: e.model ?? 'gpt-4',
156
+ provider: e.provider ?? 'openai',
157
+ promptTokens: 100,
158
+ completionTokens: 50,
159
+ cost: e.cost,
160
+ }));
161
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { aggregateUsage } from '../format.js';
3
3
  import { renderSpendSparkline } from '../chart.js';
4
- import { createTestDate, createActivityItem } from './fixtures.js';
4
+ import { createTestDate, createActivityItem, createLocalEvents } from './fixtures.js';
5
5
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
6
6
 
7
7
  describe('aggregateUsage', () => {
@@ -224,6 +224,54 @@ describe('aggregateUsage', () => {
224
224
  expect(result.byProvider[0]?.tokens.output).toBe(80); // 50 + 30
225
225
  expect(result.byProvider[0]?.requests).toBe(8); // 5 + 3
226
226
  });
227
+
228
+ it.each([
229
+ {
230
+ name: 'local today included in 7d model stats',
231
+ api: { daysAgo: 3, cost: 5, model: 'gpt-4' },
232
+ local: { daysAgo: 0, cost: 2.5, model: 'gpt-4' },
233
+ expectSpend7d: 7.5,
234
+ },
235
+ {
236
+ name: 'local outside 7d window excluded from 7d stats',
237
+ api: { daysAgo: 3, cost: 5, model: 'gpt-4' },
238
+ local: { daysAgo: 10, cost: 100, model: 'gpt-4' },
239
+ expectSpend7d: 5,
240
+ },
241
+ {
242
+ name: 'different models tracked separately',
243
+ api: { daysAgo: 1, cost: 5, model: 'gpt-4' },
244
+ local: { daysAgo: 0, cost: 3, model: 'claude-3' },
245
+ expectSpend7d: null,
246
+ expectModels: [
247
+ { name: 'gpt-4', spend7d: 5 },
248
+ { name: 'claude-3', spend7d: 3 },
249
+ ],
250
+ },
251
+ ])('$name', ({ api, local, expectSpend7d, expectModels }) => {
252
+ const credits = { totalUsage: 1000, totalCredits: 2000 };
253
+
254
+ const result = aggregateUsage(
255
+ credits,
256
+ [
257
+ createActivityItem({
258
+ date: createTestDate(api.daysAgo),
259
+ usage: api.cost,
260
+ model: api.model,
261
+ }),
262
+ ],
263
+ Date.now(),
264
+ createLocalEvents([local]),
265
+ );
266
+
267
+ if (expectModels) {
268
+ for (const { name, spend7d } of expectModels) {
269
+ expect(result.topModels.find((m) => m.name === name)?.spend7d).toBe(spend7d);
270
+ }
271
+ } else {
272
+ expect(result.topModels[0]?.spend7d).toBe(expectSpend7d);
273
+ }
274
+ });
227
275
  });
228
276
 
229
277
  describe('renderSpendSparkline', () => {
@@ -0,0 +1,414 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { ModelOverridesFile } from '../models/types.js';
3
+
4
+ const { loadModelOverrides, saveModelOverrides } = vi.hoisted(() => ({
5
+ loadModelOverrides: vi.fn<() => Promise<ModelOverridesFile>>(),
6
+ saveModelOverrides: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('../models/overrides.js', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('../models/overrides.js')>();
11
+ return {
12
+ ...actual,
13
+ loadModelOverrides,
14
+ saveModelOverrides,
15
+ };
16
+ });
17
+
18
+ import {
19
+ applyNestedValue,
20
+ handleModelOverrideClear,
21
+ handleModelOverrideList,
22
+ handleModelOverrideSet,
23
+ parseScopedAssignment,
24
+ } from '../index.js';
25
+
26
+ const emptyOverrides = (): ModelOverridesFile => ({ version: 1, overrides: {} });
27
+
28
+ describe('parseScopedAssignment', () => {
29
+ it('parses thinking shorthand aliases', () => {
30
+ expect(parseScopedAssignment('thinking.high=high')).toEqual({
31
+ fullPath: 'thinkingLevelMap.high',
32
+ value: 'high',
33
+ });
34
+ });
35
+
36
+ it('parses exact thinkingLevelMap field names', () => {
37
+ expect(parseScopedAssignment('thinkingLevelMap.xhigh=max')).toEqual({
38
+ fullPath: 'thinkingLevelMap.xhigh',
39
+ value: 'max',
40
+ });
41
+ });
42
+
43
+ it('parses null string values for thinking levels', () => {
44
+ expect(parseScopedAssignment('thinking.off=null')).toEqual({
45
+ fullPath: 'thinkingLevelMap.off',
46
+ value: null,
47
+ });
48
+ });
49
+
50
+ it('parses numeric and boolean top-level fields', () => {
51
+ expect(parseScopedAssignment('contextWindow=64000')).toEqual({
52
+ fullPath: 'contextWindow',
53
+ value: 64000,
54
+ });
55
+ expect(parseScopedAssignment('maxTokens=8192')).toEqual({
56
+ fullPath: 'maxTokens',
57
+ value: 8192,
58
+ });
59
+ expect(parseScopedAssignment('reasoning=false')).toEqual({
60
+ fullPath: 'reasoning',
61
+ value: false,
62
+ });
63
+ });
64
+
65
+ it('rejects malformed or unsupported assignments', () => {
66
+ expect(parseScopedAssignment('thinking.high')).toBeNull();
67
+ expect(parseScopedAssignment('unknown.field=value')).toBeNull();
68
+ expect(parseScopedAssignment('contextWindow=large')).toBeNull();
69
+ expect(parseScopedAssignment('reasoning=yes')).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe('applyNestedValue', () => {
74
+ it('applies nested and top-level values', () => {
75
+ const target: Record<string, unknown> = {};
76
+
77
+ applyNestedValue(target, 'thinkingLevelMap.high', 'high');
78
+ applyNestedValue(target, 'contextWindow', 64000);
79
+
80
+ expect(target).toEqual({
81
+ thinkingLevelMap: { high: 'high' },
82
+ contextWindow: 64000,
83
+ });
84
+ });
85
+
86
+ it('replaces non-object intermediate values with objects', () => {
87
+ const target: Record<string, unknown> = { thinkingLevelMap: 'bad' };
88
+
89
+ applyNestedValue(target, 'thinkingLevelMap.high', 'high');
90
+
91
+ expect(target).toEqual({ thinkingLevelMap: { high: 'high' } });
92
+ });
93
+ });
94
+
95
+ describe('handleModelOverrideSet', () => {
96
+ beforeEach(() => {
97
+ loadModelOverrides.mockReset();
98
+ saveModelOverrides.mockReset();
99
+ loadModelOverrides.mockResolvedValue(emptyOverrides());
100
+ saveModelOverrides.mockResolvedValue(undefined);
101
+ });
102
+
103
+ it('returns usage when no model id is provided', async () => {
104
+ const result = await handleModelOverrideSet('', emptyOverrides());
105
+
106
+ expect(result.success).toBe(false);
107
+ expect(result.message).toContain('Usage: /openrouter model-override-set');
108
+ expect(saveModelOverrides).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('rejects model IDs without provider/model format', async () => {
112
+ const result = await handleModelOverrideSet(
113
+ 'not-a-model contextWindow=64000',
114
+ emptyOverrides(),
115
+ );
116
+
117
+ expect(result).toEqual({
118
+ success: false,
119
+ message:
120
+ 'Invalid model ID format: "not-a-model"\nExpected format: provider/model (e.g., "deepseek/deepseek-v4-pro")',
121
+ });
122
+ expect(saveModelOverrides).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('rejects missing field assignments', async () => {
126
+ const result = await handleModelOverrideSet('test/model --dry-run', emptyOverrides());
127
+
128
+ expect(result.success).toBe(false);
129
+ expect(result.message).toContain('No field assignments provided');
130
+ expect(saveModelOverrides).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it('rejects invalid assignments', async () => {
134
+ const result = await handleModelOverrideSet('test/model unknown=value', emptyOverrides());
135
+
136
+ expect(result.success).toBe(false);
137
+ expect(result.message).toContain('Invalid assignment: "unknown=value"');
138
+ expect(saveModelOverrides).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('does not create thinkingLevelMap for non-thinking overrides', async () => {
142
+ const userOverrides = emptyOverrides();
143
+
144
+ const result = await handleModelOverrideSet('test/model contextWindow=64000', userOverrides);
145
+
146
+ expect(result.success).toBe(true);
147
+ expect(saveModelOverrides).toHaveBeenCalledWith({
148
+ version: 1,
149
+ overrides: {
150
+ 'test/model': {
151
+ contextWindow: 64000,
152
+ },
153
+ },
154
+ });
155
+ });
156
+
157
+ it('stores sparse thinking overrides without nulling unrelated levels', async () => {
158
+ const userOverrides = emptyOverrides();
159
+
160
+ const result = await handleModelOverrideSet('test/model thinking.high=high', userOverrides);
161
+
162
+ expect(result.success).toBe(true);
163
+ expect(saveModelOverrides).toHaveBeenCalledWith({
164
+ version: 1,
165
+ overrides: {
166
+ 'test/model': {
167
+ thinkingLevelMap: {
168
+ high: 'high',
169
+ },
170
+ },
171
+ },
172
+ });
173
+ });
174
+
175
+ it('persists all supported field types in one command', async () => {
176
+ const userOverrides = emptyOverrides();
177
+
178
+ const result = await handleModelOverrideSet(
179
+ 'test/model thinking.high=high thinking.xhigh=max thinking.off=null contextWindow=64000 maxTokens=8192 reasoning=true',
180
+ userOverrides,
181
+ );
182
+
183
+ expect(result).toMatchObject({ success: true, modelId: 'test/model' });
184
+ expect(result.message).toContain('Saved overrides for test/model');
185
+ expect(saveModelOverrides).toHaveBeenCalledWith({
186
+ version: 1,
187
+ overrides: {
188
+ 'test/model': {
189
+ thinkingLevelMap: {
190
+ high: 'high',
191
+ xhigh: 'max',
192
+ off: null,
193
+ },
194
+ contextWindow: 64000,
195
+ maxTokens: 8192,
196
+ reasoning: true,
197
+ },
198
+ },
199
+ });
200
+ });
201
+
202
+ it('merges with existing overrides instead of replacing them', async () => {
203
+ const userOverrides: ModelOverridesFile = {
204
+ version: 1,
205
+ overrides: {
206
+ 'test/model': {
207
+ thinkingLevelMap: { minimal: null, high: 'old-high' },
208
+ contextWindow: 128000,
209
+ },
210
+ },
211
+ };
212
+
213
+ const result = await handleModelOverrideSet(
214
+ 'test/model thinking.high=high maxTokens=8192',
215
+ userOverrides,
216
+ );
217
+
218
+ expect(result.success).toBe(true);
219
+ expect(saveModelOverrides).toHaveBeenCalledWith({
220
+ version: 1,
221
+ overrides: {
222
+ 'test/model': {
223
+ thinkingLevelMap: { minimal: null, high: 'high' },
224
+ contextWindow: 128000,
225
+ maxTokens: 8192,
226
+ },
227
+ },
228
+ });
229
+ });
230
+
231
+ it('returns a handler failure when saving overrides fails', async () => {
232
+ const userOverrides = emptyOverrides();
233
+ saveModelOverrides.mockRejectedValue(new Error('disk full'));
234
+
235
+ const result = await handleModelOverrideSet('test/model contextWindow=64000', userOverrides);
236
+
237
+ expect(result).toEqual({
238
+ success: false,
239
+ message: 'Failed to save overrides for test/model: disk full',
240
+ });
241
+ });
242
+ });
243
+
244
+ describe('handleModelOverrideClear', () => {
245
+ beforeEach(() => {
246
+ saveModelOverrides.mockReset();
247
+ saveModelOverrides.mockResolvedValue(undefined);
248
+ });
249
+
250
+ it('returns usage when no model id is provided', async () => {
251
+ const result = await handleModelOverrideClear('', emptyOverrides());
252
+
253
+ expect(result).toEqual({
254
+ success: false,
255
+ message: 'Usage: /openrouter model-override-clear <model-id>',
256
+ });
257
+ expect(saveModelOverrides).not.toHaveBeenCalled();
258
+ });
259
+
260
+ it('rejects model IDs without provider/model format', async () => {
261
+ const result = await handleModelOverrideClear('bad-model', emptyOverrides());
262
+
263
+ expect(result).toEqual({
264
+ success: false,
265
+ message: 'Invalid model ID format: "bad-model"\nExpected format: provider/model',
266
+ });
267
+ expect(saveModelOverrides).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it('reports when the model has no overrides', async () => {
271
+ const result = await handleModelOverrideClear('test/model', emptyOverrides());
272
+
273
+ expect(result).toEqual({
274
+ success: false,
275
+ message: 'No overrides found for test/model',
276
+ });
277
+ expect(saveModelOverrides).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it('removes an existing override and preserves other models', async () => {
281
+ const userOverrides: ModelOverridesFile = {
282
+ version: 1,
283
+ overrides: {
284
+ 'test/model': { contextWindow: 64000 },
285
+ 'other/model': { maxTokens: 8192 },
286
+ },
287
+ };
288
+
289
+ const result = await handleModelOverrideClear('test/model', userOverrides);
290
+
291
+ expect(result).toEqual({
292
+ success: true,
293
+ message: 'Cleared all overrides for test/model',
294
+ modelId: 'test/model',
295
+ });
296
+ expect(saveModelOverrides).toHaveBeenCalledWith({
297
+ version: 1,
298
+ overrides: {
299
+ 'other/model': { maxTokens: 8192 },
300
+ },
301
+ });
302
+ });
303
+
304
+ it('returns a handler failure when clearing overrides fails', async () => {
305
+ const userOverrides: ModelOverridesFile = {
306
+ version: 1,
307
+ overrides: {
308
+ 'test/model': {
309
+ contextWindow: 64000,
310
+ },
311
+ },
312
+ };
313
+ saveModelOverrides.mockRejectedValue(new Error('permission denied'));
314
+
315
+ const result = await handleModelOverrideClear('test/model', userOverrides);
316
+
317
+ expect(result).toEqual({
318
+ success: false,
319
+ message: 'Failed to clear overrides for test/model: permission denied',
320
+ });
321
+ });
322
+ });
323
+
324
+ describe('handleModelOverrideList', () => {
325
+ beforeEach(() => {
326
+ loadModelOverrides.mockReset();
327
+ loadModelOverrides.mockResolvedValue(emptyOverrides());
328
+ });
329
+
330
+ it('lists available fields', async () => {
331
+ const result = await handleModelOverrideList('--fields');
332
+
333
+ expect(result).toContain('Available override fields:');
334
+ expect(result).toContain('thinking.high: thinkingLevelMap.high (string)');
335
+ expect(result).toContain('contextWindow: contextWindow (number)');
336
+ expect(result).toContain('reasoning: reasoning (boolean)');
337
+ });
338
+
339
+ it('shows an empty state when no overrides exist', async () => {
340
+ const result = await handleModelOverrideList('');
341
+
342
+ expect(result).toBe(
343
+ 'No model overrides configured.\nUse /openrouter model-override-set to add overrides.',
344
+ );
345
+ });
346
+
347
+ it('reports a missing specific model override', async () => {
348
+ loadModelOverrides.mockResolvedValue({
349
+ version: 1,
350
+ overrides: {
351
+ 'other/model': { maxTokens: 8192 },
352
+ },
353
+ });
354
+
355
+ const result = await handleModelOverrideList('test/model');
356
+
357
+ expect(result).toBe('No overrides configured for test/model');
358
+ });
359
+
360
+ it('formats a specific model override with nested thinking values', async () => {
361
+ loadModelOverrides.mockResolvedValue({
362
+ version: 1,
363
+ overrides: {
364
+ 'test/model': {
365
+ thinkingLevelMap: { off: null, high: 'high', xhigh: 'max' },
366
+ contextWindow: 64000,
367
+ maxTokens: 8192,
368
+ reasoning: true,
369
+ },
370
+ },
371
+ });
372
+
373
+ const result = await handleModelOverrideList('test/model');
374
+
375
+ expect(result).toBe(
376
+ [
377
+ 'Overrides for test/model:',
378
+ ' thinkingLevelMap:',
379
+ ' off: null',
380
+ ' high: high',
381
+ ' xhigh: max',
382
+ ' contextWindow: 64000',
383
+ ' maxTokens: 8192',
384
+ ' reasoning: true',
385
+ ].join('\n'),
386
+ );
387
+ });
388
+
389
+ it('lists all override model IDs and summarizes non-null thinking values', async () => {
390
+ loadModelOverrides.mockResolvedValue({
391
+ version: 1,
392
+ overrides: {
393
+ 'thinking/model': {
394
+ thinkingLevelMap: { off: null, high: 'high', xhigh: 'max' },
395
+ },
396
+ 'top-level/model': {
397
+ contextWindow: 64000,
398
+ },
399
+ },
400
+ });
401
+
402
+ const result = await handleModelOverrideList('');
403
+
404
+ expect(result).toBe(
405
+ [
406
+ '2 model(s) with overrides:',
407
+ ' thinking/model [high=high,xhigh=max]',
408
+ ' top-level/model',
409
+ '',
410
+ 'Use /openrouter model-override-list <model-id> for details',
411
+ ].join('\n'),
412
+ );
413
+ });
414
+ });
@@ -25,13 +25,10 @@ export function aggregateUsage(
25
25
  return d.date >= utcISODate(startOfWeek);
26
26
  });
27
27
 
28
- const todayData = analytics.filter((d) => {
29
- // API dates are YYYY-MM-DD in UTC; compare by UTC date boundary
30
- return d.date >= utcISODate(startOfDay);
31
- });
32
-
33
28
  const weekFromAnalytics = sumSpend(weekData);
34
- const todayFromAnalytics = sumSpend(todayData);
29
+ const todayFromAnalytics = analytics
30
+ .filter((d) => d.date >= utcISODate(startOfDay))
31
+ .reduce((sum, d) => sum + d.usage, 0);
35
32
  const month = credits.totalUsage;
36
33
 
37
34
  // Add local events to compute combined totals
@@ -65,8 +62,10 @@ export function aggregateUsage(
65
62
  const allData = [...analytics, ...localItems];
66
63
 
67
64
  // Build model stats for both 7d and 30d windows
68
- // Use allData (combined API + local) for 30d, weekData (combined) for 7d
69
- const modelStatsMap = buildModelStats(weekData, allData);
65
+ // Combine weekData with local events in the 7d window
66
+ const weekLocalItems = localItems.filter((e) => e.date >= utcISODate(startOfWeek));
67
+ const weekAllData = [...weekData, ...weekLocalItems];
68
+ const modelStatsMap = buildModelStats(weekAllData, allData);
70
69
  const topModels = Array.from(modelStatsMap.values())
71
70
  .sort((a, b) => b.spend30d - a.spend30d)
72
71
  .slice(0, 10);