@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 +49 -1
- package/extensions/openrouter/__tests__/fixtures.ts +27 -0
- package/extensions/openrouter/__tests__/format.test.ts +49 -1
- package/extensions/openrouter/__tests__/index.test.ts +414 -0
- package/extensions/openrouter/format.ts +7 -8
- package/extensions/openrouter/index.ts +404 -3
- package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
- package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
- package/extensions/openrouter/models/mapper.ts +27 -10
- package/extensions/openrouter/models/overrides.ts +174 -0
- package/extensions/openrouter/models/types.ts +29 -0
- package/package.json +1 -1
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,
|
|
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 =
|
|
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
|
-
//
|
|
69
|
-
const
|
|
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);
|