@robhowley/pi-openrouter 0.9.0 → 0.10.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 +46 -5
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +529 -0
- package/extensions/openrouter/__tests__/index.test.ts +112 -363
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/__tests__/status-bar.test.ts +262 -0
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +288 -0
- package/extensions/openrouter/index.ts +13 -990
- package/extensions/openrouter/local-usage.ts +158 -30
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +35 -69
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +2 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/status-bar.ts +101 -0
- package/extensions/openrouter/types.ts +28 -9
- package/package.json +1 -1
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ModelOverridesFile } from '../types.js';
|
|
3
|
+
|
|
4
|
+
const { loadModelOverrides, saveModelOverrides } = vi.hoisted(() => ({
|
|
5
|
+
loadModelOverrides: vi.fn<() => Promise<ModelOverridesFile>>(),
|
|
6
|
+
saveModelOverrides: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('../overrides.js', async (importOriginal) => {
|
|
10
|
+
const actual = await importOriginal<typeof import('../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
|
+
SCOPED_FIELD_MAP,
|
|
25
|
+
validateThinkingValue,
|
|
26
|
+
} from '../override-commands.js';
|
|
27
|
+
|
|
28
|
+
const emptyOverrides = (): ModelOverridesFile => ({ version: 1, overrides: {} });
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Validation Tests
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
describe('validateThinkingValue', () => {
|
|
35
|
+
it('accepts null (hide level signal)', () => {
|
|
36
|
+
expect(validateThinkingValue(null)).toEqual({ valid: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('accepts documented thinking values', () => {
|
|
40
|
+
const validValues = ['off', 'minimal', 'low', 'medium', 'high', 'max', 'xhigh'];
|
|
41
|
+
|
|
42
|
+
for (const value of validValues) {
|
|
43
|
+
expect(validateThinkingValue(value)).toEqual({ valid: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects empty string', () => {
|
|
48
|
+
const result = validateThinkingValue('');
|
|
49
|
+
expect(result.valid).toBe(false);
|
|
50
|
+
expect(result.error).toContain('empty');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects whitespace-only values', () => {
|
|
54
|
+
const result = validateThinkingValue(' ');
|
|
55
|
+
expect(result.valid).toBe(false);
|
|
56
|
+
expect(result.error).toContain('whitespace');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects control characters', () => {
|
|
60
|
+
// Test null byte
|
|
61
|
+
const result1 = validateThinkingValue('high\x00');
|
|
62
|
+
expect(result1.valid).toBe(false);
|
|
63
|
+
expect(result1.error).toContain('control');
|
|
64
|
+
|
|
65
|
+
// Test bell character
|
|
66
|
+
const result2 = validateThinkingValue('\x07high');
|
|
67
|
+
expect(result2.valid).toBe(false);
|
|
68
|
+
expect(result2.error).toContain('control');
|
|
69
|
+
|
|
70
|
+
// Test DEL character
|
|
71
|
+
const result3 = validateThinkingValue('high\x7F');
|
|
72
|
+
expect(result3.valid).toBe(false);
|
|
73
|
+
expect(result3.error).toContain('control');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects values not in the allowed set', () => {
|
|
77
|
+
const result = validateThinkingValue('ultra-max-plus');
|
|
78
|
+
expect(result.valid).toBe(false);
|
|
79
|
+
expect(result.error).toContain('not in the allowed set');
|
|
80
|
+
expect(result.error).toContain('model-overrides.json');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects arbitrary string values', () => {
|
|
84
|
+
const invalidValues = ['random', 'custom-value', '123', 'HIGH'];
|
|
85
|
+
|
|
86
|
+
for (const value of invalidValues) {
|
|
87
|
+
const result = validateThinkingValue(value);
|
|
88
|
+
expect(result.valid).toBe(false);
|
|
89
|
+
expect(result.error).toContain('not in the allowed set');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// DSL Parsing Tests (Characterization + Validation)
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
describe('parseScopedAssignment', () => {
|
|
99
|
+
describe('existing documented behavior', () => {
|
|
100
|
+
it('parses thinking shorthand aliases', () => {
|
|
101
|
+
expect(parseScopedAssignment('thinking.high=high')).toEqual({
|
|
102
|
+
ok: true,
|
|
103
|
+
fullPath: 'thinkingLevelMap.high',
|
|
104
|
+
value: 'high',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('parses exact thinkingLevelMap field names', () => {
|
|
109
|
+
expect(parseScopedAssignment('thinkingLevelMap.xhigh=max')).toEqual({
|
|
110
|
+
ok: true,
|
|
111
|
+
fullPath: 'thinkingLevelMap.xhigh',
|
|
112
|
+
value: 'max',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('parses null string values for thinking levels', () => {
|
|
117
|
+
expect(parseScopedAssignment('thinking.off=null')).toEqual({
|
|
118
|
+
ok: true,
|
|
119
|
+
fullPath: 'thinkingLevelMap.off',
|
|
120
|
+
value: null,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('parses numeric and boolean top-level fields', () => {
|
|
125
|
+
expect(parseScopedAssignment('contextWindow=64000')).toEqual({
|
|
126
|
+
ok: true,
|
|
127
|
+
fullPath: 'contextWindow',
|
|
128
|
+
value: 64000,
|
|
129
|
+
});
|
|
130
|
+
expect(parseScopedAssignment('maxTokens=8192')).toEqual({
|
|
131
|
+
ok: true,
|
|
132
|
+
fullPath: 'maxTokens',
|
|
133
|
+
value: 8192,
|
|
134
|
+
});
|
|
135
|
+
expect(parseScopedAssignment('reasoning=false')).toEqual({
|
|
136
|
+
ok: true,
|
|
137
|
+
fullPath: 'reasoning',
|
|
138
|
+
value: false,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects malformed or unsupported assignments', () => {
|
|
143
|
+
expect(parseScopedAssignment('thinking.high')).toEqual({
|
|
144
|
+
ok: false,
|
|
145
|
+
code: 'invalid-assignment',
|
|
146
|
+
});
|
|
147
|
+
expect(parseScopedAssignment('unknown.field=value')).toEqual({
|
|
148
|
+
ok: false,
|
|
149
|
+
code: 'invalid-assignment',
|
|
150
|
+
});
|
|
151
|
+
expect(parseScopedAssignment('contextWindow=large')).toEqual({
|
|
152
|
+
ok: false,
|
|
153
|
+
code: 'invalid-assignment',
|
|
154
|
+
});
|
|
155
|
+
expect(parseScopedAssignment('reasoning=yes')).toEqual({
|
|
156
|
+
ok: false,
|
|
157
|
+
code: 'invalid-assignment',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('validation enforcement (Phase 7)', () => {
|
|
163
|
+
it('rejects empty thinking values with specific error', () => {
|
|
164
|
+
const result = parseScopedAssignment('thinking.high=');
|
|
165
|
+
expect(result).toMatchObject({
|
|
166
|
+
ok: false,
|
|
167
|
+
code: 'invalid-thinking-value',
|
|
168
|
+
field: 'thinking.high',
|
|
169
|
+
});
|
|
170
|
+
if (result.ok === false && result.code === 'invalid-thinking-value') {
|
|
171
|
+
expect(result.message).toContain('empty');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rejects whitespace-only thinking values with specific error', () => {
|
|
176
|
+
const result = parseScopedAssignment('thinking.high= ');
|
|
177
|
+
expect(result).toMatchObject({
|
|
178
|
+
ok: false,
|
|
179
|
+
code: 'invalid-thinking-value',
|
|
180
|
+
field: 'thinking.high',
|
|
181
|
+
});
|
|
182
|
+
if (result.ok === false && result.code === 'invalid-thinking-value') {
|
|
183
|
+
expect(result.message).toContain('whitespace');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('rejects thinking values with control characters with specific error', () => {
|
|
188
|
+
const result1 = parseScopedAssignment('thinking.high=high\x00');
|
|
189
|
+
expect(result1).toMatchObject({
|
|
190
|
+
ok: false,
|
|
191
|
+
code: 'invalid-thinking-value',
|
|
192
|
+
field: 'thinking.high',
|
|
193
|
+
});
|
|
194
|
+
if (result1.ok === false && result1.code === 'invalid-thinking-value') {
|
|
195
|
+
expect(result1.message).toContain('control');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const result2 = parseScopedAssignment('thinking.high=\x07high');
|
|
199
|
+
expect(result2).toMatchObject({
|
|
200
|
+
ok: false,
|
|
201
|
+
code: 'invalid-thinking-value',
|
|
202
|
+
field: 'thinking.high',
|
|
203
|
+
});
|
|
204
|
+
if (result2.ok === false && result2.code === 'invalid-thinking-value') {
|
|
205
|
+
expect(result2.message).toContain('control');
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('rejects undocumented thinking values with specific error', () => {
|
|
210
|
+
const result1 = parseScopedAssignment('thinking.high=ultra');
|
|
211
|
+
expect(result1).toMatchObject({
|
|
212
|
+
ok: false,
|
|
213
|
+
code: 'invalid-thinking-value',
|
|
214
|
+
field: 'thinking.high',
|
|
215
|
+
});
|
|
216
|
+
if (result1.ok === false && result1.code === 'invalid-thinking-value') {
|
|
217
|
+
expect(result1.message).toContain('not in the allowed set');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const result2 = parseScopedAssignment('thinking.high=custom-value');
|
|
221
|
+
expect(result2).toMatchObject({
|
|
222
|
+
ok: false,
|
|
223
|
+
code: 'invalid-thinking-value',
|
|
224
|
+
field: 'thinking.high',
|
|
225
|
+
});
|
|
226
|
+
if (result2.ok === false && result2.code === 'invalid-thinking-value') {
|
|
227
|
+
expect(result2.message).toContain('not in the allowed set');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result3 = parseScopedAssignment('thinking.high=HIGH');
|
|
231
|
+
expect(result3).toMatchObject({
|
|
232
|
+
ok: false,
|
|
233
|
+
code: 'invalid-thinking-value',
|
|
234
|
+
field: 'thinking.high',
|
|
235
|
+
});
|
|
236
|
+
if (result3.ok === false && result3.code === 'invalid-thinking-value') {
|
|
237
|
+
expect(result3.message).toContain('not in the allowed set');
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('accepts all documented thinking values', () => {
|
|
242
|
+
const validPairs: Array<[string, string]> = [
|
|
243
|
+
['thinking.off=off', 'off'],
|
|
244
|
+
['thinking.minimal=minimal', 'minimal'],
|
|
245
|
+
['thinking.low=low', 'low'],
|
|
246
|
+
['thinking.medium=medium', 'medium'],
|
|
247
|
+
['thinking.high=high', 'high'],
|
|
248
|
+
['thinking.xhigh=max', 'max'],
|
|
249
|
+
['thinking.xhigh=xhigh', 'xhigh'],
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
for (const [input, expectedValue] of validPairs) {
|
|
253
|
+
const result = parseScopedAssignment(input);
|
|
254
|
+
expect(result.ok).toBe(true);
|
|
255
|
+
if (result.ok) {
|
|
256
|
+
expect(result.value).toBe(expectedValue);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('does not validate non-thinking fields', () => {
|
|
262
|
+
// contextWindow and other fields should not be affected by thinking validation
|
|
263
|
+
expect(parseScopedAssignment('contextWindow=999999')).toEqual({
|
|
264
|
+
ok: true,
|
|
265
|
+
fullPath: 'contextWindow',
|
|
266
|
+
value: 999999,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('applyNestedValue', () => {
|
|
273
|
+
it('applies nested and top-level values', () => {
|
|
274
|
+
const target: Record<string, unknown> = {};
|
|
275
|
+
|
|
276
|
+
applyNestedValue(target, 'thinkingLevelMap.high', 'high');
|
|
277
|
+
applyNestedValue(target, 'contextWindow', 64000);
|
|
278
|
+
|
|
279
|
+
expect(target).toEqual({
|
|
280
|
+
thinkingLevelMap: { high: 'high' },
|
|
281
|
+
contextWindow: 64000,
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('replaces non-object intermediate values with objects', () => {
|
|
286
|
+
const target: Record<string, unknown> = { thinkingLevelMap: 'bad' };
|
|
287
|
+
|
|
288
|
+
applyNestedValue(target, 'thinkingLevelMap.high', 'high');
|
|
289
|
+
|
|
290
|
+
expect(target).toEqual({ thinkingLevelMap: { high: 'high' } });
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// =============================================================================
|
|
295
|
+
// Command Handler Tests (Characterization)
|
|
296
|
+
// =============================================================================
|
|
297
|
+
|
|
298
|
+
describe('handleModelOverrideSet', () => {
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
loadModelOverrides.mockReset();
|
|
301
|
+
saveModelOverrides.mockReset();
|
|
302
|
+
loadModelOverrides.mockResolvedValue(emptyOverrides());
|
|
303
|
+
saveModelOverrides.mockResolvedValue(undefined);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('returns usage when no model id is provided', async () => {
|
|
307
|
+
const result = await handleModelOverrideSet('', emptyOverrides());
|
|
308
|
+
|
|
309
|
+
expect(result.success).toBe(false);
|
|
310
|
+
expect(result.message).toContain('Usage: /openrouter model-override-set');
|
|
311
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('rejects model IDs without provider/model format', async () => {
|
|
315
|
+
const result = await handleModelOverrideSet(
|
|
316
|
+
'not-a-model contextWindow=64000',
|
|
317
|
+
emptyOverrides(),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
expect(result).toEqual({
|
|
321
|
+
success: false,
|
|
322
|
+
message:
|
|
323
|
+
'Invalid model ID format: "not-a-model"\nExpected format: provider/model (e.g., "deepseek/deepseek-v4-pro")',
|
|
324
|
+
});
|
|
325
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('rejects missing field assignments', async () => {
|
|
329
|
+
const result = await handleModelOverrideSet('test/model --dry-run', emptyOverrides());
|
|
330
|
+
|
|
331
|
+
expect(result.success).toBe(false);
|
|
332
|
+
expect(result.message).toContain('No field assignments provided');
|
|
333
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('rejects invalid assignments', async () => {
|
|
337
|
+
const result = await handleModelOverrideSet('test/model unknown=value', emptyOverrides());
|
|
338
|
+
|
|
339
|
+
expect(result.success).toBe(false);
|
|
340
|
+
expect(result.message).toContain('Invalid assignment: "unknown=value"');
|
|
341
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('rejects invalid thinking values in assignments with specific error', async () => {
|
|
345
|
+
const result = await handleModelOverrideSet(
|
|
346
|
+
'test/model thinking.high=ultra-max',
|
|
347
|
+
emptyOverrides(),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
expect(result.success).toBe(false);
|
|
351
|
+
expect(result.message).toContain('Invalid thinking value');
|
|
352
|
+
expect(result.message).toContain('thinking.high');
|
|
353
|
+
expect(result.message).toContain('not in the allowed set');
|
|
354
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('rejects empty thinking values in assignments with specific error', async () => {
|
|
358
|
+
const result = await handleModelOverrideSet('test/model thinking.high=', emptyOverrides());
|
|
359
|
+
|
|
360
|
+
expect(result.success).toBe(false);
|
|
361
|
+
expect(result.message).toContain('Invalid thinking value');
|
|
362
|
+
expect(result.message).toContain('thinking.high');
|
|
363
|
+
expect(result.message).toContain('empty');
|
|
364
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('does not create thinkingLevelMap for non-thinking overrides', async () => {
|
|
368
|
+
const userOverrides = emptyOverrides();
|
|
369
|
+
|
|
370
|
+
const result = await handleModelOverrideSet('test/model contextWindow=64000', userOverrides);
|
|
371
|
+
|
|
372
|
+
expect(result.success).toBe(true);
|
|
373
|
+
expect(saveModelOverrides).toHaveBeenCalledWith({
|
|
374
|
+
version: 1,
|
|
375
|
+
overrides: {
|
|
376
|
+
'test/model': {
|
|
377
|
+
contextWindow: 64000,
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('stores sparse thinking overrides without nulling unrelated levels', async () => {
|
|
384
|
+
const userOverrides = emptyOverrides();
|
|
385
|
+
|
|
386
|
+
const result = await handleModelOverrideSet('test/model thinking.high=high', userOverrides);
|
|
387
|
+
|
|
388
|
+
expect(result.success).toBe(true);
|
|
389
|
+
expect(saveModelOverrides).toHaveBeenCalledWith({
|
|
390
|
+
version: 1,
|
|
391
|
+
overrides: {
|
|
392
|
+
'test/model': {
|
|
393
|
+
thinkingLevelMap: {
|
|
394
|
+
high: 'high',
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('persists all supported field types in one command', async () => {
|
|
402
|
+
const userOverrides = emptyOverrides();
|
|
403
|
+
|
|
404
|
+
const result = await handleModelOverrideSet(
|
|
405
|
+
'test/model thinking.high=high thinking.xhigh=max thinking.off=null contextWindow=64000 maxTokens=8192 reasoning=true',
|
|
406
|
+
userOverrides,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
expect(result).toMatchObject({ success: true, modelId: 'test/model' });
|
|
410
|
+
expect(result.message).toContain('Saved overrides for test/model');
|
|
411
|
+
expect(saveModelOverrides).toHaveBeenCalledWith({
|
|
412
|
+
version: 1,
|
|
413
|
+
overrides: {
|
|
414
|
+
'test/model': {
|
|
415
|
+
thinkingLevelMap: {
|
|
416
|
+
high: 'high',
|
|
417
|
+
xhigh: 'max',
|
|
418
|
+
off: null,
|
|
419
|
+
},
|
|
420
|
+
contextWindow: 64000,
|
|
421
|
+
maxTokens: 8192,
|
|
422
|
+
reasoning: true,
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('merges with existing overrides instead of replacing them', async () => {
|
|
429
|
+
const userOverrides: ModelOverridesFile = {
|
|
430
|
+
version: 1,
|
|
431
|
+
overrides: {
|
|
432
|
+
'test/model': {
|
|
433
|
+
thinkingLevelMap: { minimal: null, high: 'old-high' },
|
|
434
|
+
contextWindow: 128000,
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const result = await handleModelOverrideSet(
|
|
440
|
+
'test/model thinking.high=high maxTokens=8192',
|
|
441
|
+
userOverrides,
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
expect(result.success).toBe(true);
|
|
445
|
+
expect(saveModelOverrides).toHaveBeenCalledWith({
|
|
446
|
+
version: 1,
|
|
447
|
+
overrides: {
|
|
448
|
+
'test/model': {
|
|
449
|
+
thinkingLevelMap: { minimal: null, high: 'high' },
|
|
450
|
+
contextWindow: 128000,
|
|
451
|
+
maxTokens: 8192,
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('returns a handler failure when saving overrides fails', async () => {
|
|
458
|
+
const userOverrides = emptyOverrides();
|
|
459
|
+
saveModelOverrides.mockRejectedValue(new Error('disk full'));
|
|
460
|
+
|
|
461
|
+
const result = await handleModelOverrideSet('test/model contextWindow=64000', userOverrides);
|
|
462
|
+
|
|
463
|
+
expect(result).toEqual({
|
|
464
|
+
success: false,
|
|
465
|
+
message: 'Failed to save overrides for test/model: disk full',
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('handleModelOverrideClear', () => {
|
|
471
|
+
beforeEach(() => {
|
|
472
|
+
saveModelOverrides.mockReset();
|
|
473
|
+
saveModelOverrides.mockResolvedValue(undefined);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('returns usage when no model id is provided', async () => {
|
|
477
|
+
const result = await handleModelOverrideClear('', emptyOverrides());
|
|
478
|
+
|
|
479
|
+
expect(result.success).toBe(false);
|
|
480
|
+
expect(result.message).toContain('Usage: /openrouter model-override-clear');
|
|
481
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('rejects model IDs without provider/model format', async () => {
|
|
485
|
+
const result = await handleModelOverrideClear('not-a-model', emptyOverrides());
|
|
486
|
+
|
|
487
|
+
expect(result).toEqual({
|
|
488
|
+
success: false,
|
|
489
|
+
message: 'Invalid model ID format: "not-a-model"\nExpected format: provider/model',
|
|
490
|
+
});
|
|
491
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('reports when the model has no overrides', async () => {
|
|
495
|
+
const result = await handleModelOverrideClear('test/model', emptyOverrides());
|
|
496
|
+
|
|
497
|
+
expect(result).toEqual({
|
|
498
|
+
success: false,
|
|
499
|
+
message: 'No overrides found for test/model',
|
|
500
|
+
});
|
|
501
|
+
expect(saveModelOverrides).not.toHaveBeenCalled();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('removes an existing override and preserves other models', async () => {
|
|
505
|
+
const userOverrides: ModelOverridesFile = {
|
|
506
|
+
version: 1,
|
|
507
|
+
overrides: {
|
|
508
|
+
'test/model': { contextWindow: 64000 },
|
|
509
|
+
'other/model': { maxTokens: 8192 },
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const result = await handleModelOverrideClear('test/model', userOverrides);
|
|
514
|
+
|
|
515
|
+
expect(result).toEqual({
|
|
516
|
+
success: true,
|
|
517
|
+
message: 'Cleared all overrides for test/model',
|
|
518
|
+
modelId: 'test/model',
|
|
519
|
+
});
|
|
520
|
+
expect(saveModelOverrides).toHaveBeenCalledWith({
|
|
521
|
+
version: 1,
|
|
522
|
+
overrides: {
|
|
523
|
+
'other/model': { maxTokens: 8192 },
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('returns a handler failure when clearing overrides fails', async () => {
|
|
529
|
+
const userOverrides: ModelOverridesFile = {
|
|
530
|
+
version: 1,
|
|
531
|
+
overrides: { 'test/model': { contextWindow: 64000 } },
|
|
532
|
+
};
|
|
533
|
+
saveModelOverrides.mockRejectedValue(new Error('permission denied'));
|
|
534
|
+
|
|
535
|
+
const result = await handleModelOverrideClear('test/model', userOverrides);
|
|
536
|
+
|
|
537
|
+
expect(result).toEqual({
|
|
538
|
+
success: false,
|
|
539
|
+
message: 'Failed to clear overrides for test/model: permission denied',
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe('handleModelOverrideList', () => {
|
|
545
|
+
beforeEach(() => {
|
|
546
|
+
loadModelOverrides.mockReset();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('lists available fields', async () => {
|
|
550
|
+
loadModelOverrides.mockResolvedValue(emptyOverrides());
|
|
551
|
+
|
|
552
|
+
const result = await handleModelOverrideList('--fields');
|
|
553
|
+
|
|
554
|
+
expect(result).toContain('Available override fields');
|
|
555
|
+
expect(result).toContain('thinking.high');
|
|
556
|
+
expect(result).toContain('contextWindow');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('shows an empty state when no overrides exist', async () => {
|
|
560
|
+
loadModelOverrides.mockResolvedValue(emptyOverrides());
|
|
561
|
+
|
|
562
|
+
const result = await handleModelOverrideList('');
|
|
563
|
+
|
|
564
|
+
expect(result).toContain('No model overrides configured');
|
|
565
|
+
expect(result).toContain('model-override-set');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('reports a missing specific model override', async () => {
|
|
569
|
+
loadModelOverrides.mockResolvedValue(emptyOverrides());
|
|
570
|
+
|
|
571
|
+
const result = await handleModelOverrideList('test/model');
|
|
572
|
+
|
|
573
|
+
expect(result).toBe('No overrides configured for test/model');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('formats a specific model override with nested thinking values', async () => {
|
|
577
|
+
loadModelOverrides.mockResolvedValue({
|
|
578
|
+
version: 1,
|
|
579
|
+
overrides: {
|
|
580
|
+
'test/model': {
|
|
581
|
+
thinkingLevelMap: { high: 'high', xhigh: 'max', off: null },
|
|
582
|
+
contextWindow: 64000,
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const result = await handleModelOverrideList('test/model');
|
|
588
|
+
|
|
589
|
+
expect(result).toContain('Overrides for test/model');
|
|
590
|
+
expect(result).toContain('thinkingLevelMap:');
|
|
591
|
+
expect(result).toContain('high: high');
|
|
592
|
+
expect(result).toContain('xhigh: max');
|
|
593
|
+
expect(result).toContain('off: null');
|
|
594
|
+
expect(result).toContain('contextWindow: 64000');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('lists all override model IDs and summarizes non-null thinking values', async () => {
|
|
598
|
+
loadModelOverrides.mockResolvedValue({
|
|
599
|
+
version: 1,
|
|
600
|
+
overrides: {
|
|
601
|
+
'test/model1': { thinkingLevelMap: { high: 'high', off: null } },
|
|
602
|
+
'test/model2': { contextWindow: 128000 },
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const result = await handleModelOverrideList('');
|
|
607
|
+
|
|
608
|
+
expect(result).toContain('2 model(s) with overrides');
|
|
609
|
+
expect(result).toContain('test/model1 [high=high]');
|
|
610
|
+
expect(result).toContain('test/model2');
|
|
611
|
+
expect(result).toContain('model-override-list <model-id>');
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// =============================================================================
|
|
616
|
+
// Field Map Tests (Characterization)
|
|
617
|
+
// =============================================================================
|
|
618
|
+
|
|
619
|
+
describe('SCOPED_FIELD_MAP', () => {
|
|
620
|
+
it('includes all documented thinking shorthand aliases', () => {
|
|
621
|
+
const expectedShorthands = [
|
|
622
|
+
'thinking.off',
|
|
623
|
+
'thinking.minimal',
|
|
624
|
+
'thinking.low',
|
|
625
|
+
'thinking.medium',
|
|
626
|
+
'thinking.high',
|
|
627
|
+
'thinking.xhigh',
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
for (const shorthand of expectedShorthands) {
|
|
631
|
+
expect(SCOPED_FIELD_MAP[shorthand]).toBeDefined();
|
|
632
|
+
expect(SCOPED_FIELD_MAP[shorthand]?.targetType).toBe('string');
|
|
633
|
+
expect(SCOPED_FIELD_MAP[shorthand]?.targetField).toContain('thinkingLevelMap');
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('includes exact thinkingLevelMap field names', () => {
|
|
638
|
+
const expectedExact = [
|
|
639
|
+
'thinkingLevelMap.off',
|
|
640
|
+
'thinkingLevelMap.minimal',
|
|
641
|
+
'thinkingLevelMap.low',
|
|
642
|
+
'thinkingLevelMap.medium',
|
|
643
|
+
'thinkingLevelMap.high',
|
|
644
|
+
'thinkingLevelMap.xhigh',
|
|
645
|
+
];
|
|
646
|
+
|
|
647
|
+
for (const exact of expectedExact) {
|
|
648
|
+
expect(SCOPED_FIELD_MAP[exact]).toBeDefined();
|
|
649
|
+
expect(SCOPED_FIELD_MAP[exact]?.targetType).toBe('string');
|
|
650
|
+
expect(SCOPED_FIELD_MAP[exact]?.targetField).toBe(exact);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('includes top-level extensibility fields', () => {
|
|
655
|
+
expect(SCOPED_FIELD_MAP['contextWindow']).toEqual({
|
|
656
|
+
targetField: 'contextWindow',
|
|
657
|
+
targetType: 'number',
|
|
658
|
+
});
|
|
659
|
+
expect(SCOPED_FIELD_MAP['maxTokens']).toEqual({
|
|
660
|
+
targetField: 'maxTokens',
|
|
661
|
+
targetType: 'number',
|
|
662
|
+
});
|
|
663
|
+
expect(SCOPED_FIELD_MAP['reasoning']).toEqual({
|
|
664
|
+
targetField: 'reasoning',
|
|
665
|
+
targetType: 'boolean',
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
});
|