@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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
loadModelOverrides,
|
|
4
|
+
ModelOverridesLoadError,
|
|
5
|
+
saveModelOverrides,
|
|
6
|
+
setModelOverride,
|
|
7
|
+
removeModelOverride,
|
|
8
|
+
} from '../overrides.js';
|
|
9
|
+
import type { ModelOverridesFile, UserModelOverride } from '../types.js';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
// Mock node:fs
|
|
16
|
+
vi.mock('node:fs', () => ({
|
|
17
|
+
existsSync: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock node:fs/promises
|
|
21
|
+
vi.mock('node:fs/promises', () => ({
|
|
22
|
+
readFile: vi.fn(),
|
|
23
|
+
writeFile: vi.fn(),
|
|
24
|
+
mkdir: vi.fn(),
|
|
25
|
+
rm: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock node:os
|
|
29
|
+
vi.mock('node:os', () => ({
|
|
30
|
+
homedir: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const OVERRIDES_FILE = join('/mock/home', '.pi', 'openrouter', 'model-overrides.json');
|
|
34
|
+
|
|
35
|
+
describe('overrides', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.mocked(homedir).mockReturnValue('/mock/home');
|
|
38
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
39
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
40
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
41
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
42
|
+
vi.mocked(rm).mockResolvedValue(undefined);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('loadModelOverrides', () => {
|
|
50
|
+
it('should return empty overrides when file does not exist', async () => {
|
|
51
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
52
|
+
|
|
53
|
+
const result = await loadModelOverrides();
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({ version: 1, overrides: {} });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should throw when file is invalid JSON', async () => {
|
|
59
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
60
|
+
vi.mocked(readFile).mockResolvedValue('invalid json');
|
|
61
|
+
|
|
62
|
+
await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
|
|
63
|
+
await expect(loadModelOverrides()).rejects.toThrow('Invalid JSON in model overrides file');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should throw when file has wrong structure', async () => {
|
|
67
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
68
|
+
vi.mocked(readFile).mockResolvedValue(JSON.stringify({ foo: 'bar' }));
|
|
69
|
+
|
|
70
|
+
await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
|
|
71
|
+
await expect(loadModelOverrides()).rejects.toThrow('Invalid model overrides file structure');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should throw when existing file cannot be read', async () => {
|
|
75
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
76
|
+
vi.mocked(readFile).mockRejectedValue(new Error('permission denied'));
|
|
77
|
+
|
|
78
|
+
await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
|
|
79
|
+
await expect(loadModelOverrides()).rejects.toThrow('Failed to read model overrides file');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should load valid overrides file', async () => {
|
|
83
|
+
const mockData: ModelOverridesFile = {
|
|
84
|
+
version: 1,
|
|
85
|
+
overrides: {
|
|
86
|
+
'test/model': {
|
|
87
|
+
thinkingLevelMap: { high: 'high', xhigh: 'max' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
92
|
+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockData));
|
|
93
|
+
|
|
94
|
+
const result = await loadModelOverrides();
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual(mockData);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('saveModelOverrides', () => {
|
|
101
|
+
it('should create directory if it does not exist', async () => {
|
|
102
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
103
|
+
|
|
104
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
105
|
+
await saveModelOverrides(overrides);
|
|
106
|
+
|
|
107
|
+
expect(mkdir).toHaveBeenCalledWith(join('/mock/home', '.pi', 'openrouter'), {
|
|
108
|
+
recursive: true,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should write JSON to file', async () => {
|
|
113
|
+
const overrides: ModelOverridesFile = {
|
|
114
|
+
version: 1,
|
|
115
|
+
overrides: {
|
|
116
|
+
'test/model': {
|
|
117
|
+
thinkingLevelMap: { high: 'high' },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
await saveModelOverrides(overrides);
|
|
123
|
+
|
|
124
|
+
expect(writeFile).toHaveBeenCalledWith(
|
|
125
|
+
OVERRIDES_FILE,
|
|
126
|
+
JSON.stringify(overrides, null, 2),
|
|
127
|
+
'utf-8',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('setModelOverride', () => {
|
|
133
|
+
it('should add new override', () => {
|
|
134
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
135
|
+
const override: UserModelOverride = {
|
|
136
|
+
thinkingLevelMap: { high: 'high' },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const result = setModelOverride(overrides, 'test/model', override);
|
|
140
|
+
|
|
141
|
+
expect(result.overrides['test/model']).toEqual({
|
|
142
|
+
thinkingLevelMap: { high: 'high' },
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should merge with existing override', () => {
|
|
147
|
+
const existing: UserModelOverride = {
|
|
148
|
+
thinkingLevelMap: { minimal: null, high: 'low' },
|
|
149
|
+
contextWindow: 128000,
|
|
150
|
+
};
|
|
151
|
+
const overrides: ModelOverridesFile = {
|
|
152
|
+
version: 1,
|
|
153
|
+
overrides: { 'test/model': existing },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const newOverride: UserModelOverride = {
|
|
157
|
+
thinkingLevelMap: { high: 'high' },
|
|
158
|
+
maxTokens: 8192,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const result = setModelOverride(overrides, 'test/model', newOverride);
|
|
162
|
+
|
|
163
|
+
expect(result.overrides['test/model']).toEqual({
|
|
164
|
+
thinkingLevelMap: { minimal: null, high: 'high' },
|
|
165
|
+
contextWindow: 128000,
|
|
166
|
+
maxTokens: 8192,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should clean up undefined thinkingLevelMap entries', () => {
|
|
171
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
172
|
+
const override: UserModelOverride = {
|
|
173
|
+
thinkingLevelMap: { high: 'high', medium: undefined as unknown as null },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const result = setModelOverride(overrides, 'test/model', override);
|
|
177
|
+
|
|
178
|
+
expect(result.overrides['test/model']?.thinkingLevelMap).toEqual({
|
|
179
|
+
high: 'high',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should remove empty thinkingLevelMap', () => {
|
|
184
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
185
|
+
const override: UserModelOverride = {
|
|
186
|
+
thinkingLevelMap: {},
|
|
187
|
+
contextWindow: 128000,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = setModelOverride(overrides, 'test/model', override);
|
|
191
|
+
|
|
192
|
+
expect(result.overrides['test/model']?.thinkingLevelMap).toBeUndefined();
|
|
193
|
+
expect(result.overrides['test/model']?.contextWindow).toBe(128000);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should not create thinkingLevelMap for non-thinking overrides', () => {
|
|
197
|
+
const overrides: ModelOverridesFile = { version: 1, overrides: {} };
|
|
198
|
+
|
|
199
|
+
const result = setModelOverride(overrides, 'test/model', {
|
|
200
|
+
contextWindow: 64000,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.overrides['test/model']).toEqual({
|
|
204
|
+
contextWindow: 64000,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('removeModelOverride', () => {
|
|
210
|
+
it('should remove existing override', () => {
|
|
211
|
+
const overrides: ModelOverridesFile = {
|
|
212
|
+
version: 1,
|
|
213
|
+
overrides: {
|
|
214
|
+
'test/model': { thinkingLevelMap: { high: 'high' } },
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = removeModelOverride(overrides, 'test/model');
|
|
219
|
+
|
|
220
|
+
expect(result.overrides['test/model']).toBeUndefined();
|
|
221
|
+
expect(Object.keys(result.overrides)).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should be idempotent for non-existent model', () => {
|
|
225
|
+
const overrides: ModelOverridesFile = {
|
|
226
|
+
version: 1,
|
|
227
|
+
overrides: {
|
|
228
|
+
'other/model': { thinkingLevelMap: { high: 'high' } },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const result = removeModelOverride(overrides, 'unknown/model');
|
|
233
|
+
|
|
234
|
+
expect(result).toEqual(overrides);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpenRouterModel, PiModelConfig, SkipReason, MapResult } from './types.js';
|
|
2
2
|
import { ROUTER_ALIASES } from './types.js';
|
|
3
3
|
import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
|
|
4
|
+
import { loadModelOverrides, getModelOverride } from './overrides.js';
|
|
4
5
|
|
|
5
6
|
// Cache for built-in OpenRouter models from pi-ai
|
|
6
7
|
// Populated lazily on first access
|
|
@@ -20,7 +21,6 @@ async function loadBuiltInOpenRouterModels(): Promise<Map<string, PiModelConfig>
|
|
|
20
21
|
|
|
21
22
|
try {
|
|
22
23
|
// Import from pi-ai to get built-in model registry
|
|
23
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
24
|
const { getModels } = (await import('@earendil-works/pi-ai')) as {
|
|
25
25
|
getModels: (provider: string) => unknown[];
|
|
26
26
|
};
|
|
@@ -29,7 +29,6 @@ async function loadBuiltInOpenRouterModels(): Promise<Map<string, PiModelConfig>
|
|
|
29
29
|
if (Array.isArray(openrouterModels)) {
|
|
30
30
|
for (const model of openrouterModels) {
|
|
31
31
|
// Extract thinkingLevelMap from built-in model if present
|
|
32
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
32
|
const modelWithThinking = model as { id: string; thinkingLevelMap?: unknown };
|
|
34
33
|
if (modelWithThinking.id) {
|
|
35
34
|
models.set(modelWithThinking.id, model as PiModelConfig);
|
|
@@ -155,11 +154,13 @@ function validateModel(model: OpenRouterModel): ValidationResult {
|
|
|
155
154
|
|
|
156
155
|
/**
|
|
157
156
|
* Build PiModelConfig from a validated OpenRouterModel.
|
|
158
|
-
* Merges thinkingLevelMap from Pi's built-in registry
|
|
157
|
+
* Merges thinkingLevelMap from Pi's built-in registry and user overrides.
|
|
158
|
+
* Priority: user overrides > built-in registry > API data
|
|
159
159
|
*/
|
|
160
160
|
async function buildPiConfig(
|
|
161
161
|
model: OpenRouterModel,
|
|
162
162
|
contextWindow: number,
|
|
163
|
+
userOverrides?: Awaited<ReturnType<typeof loadModelOverrides>>,
|
|
163
164
|
): Promise<PiModelConfig> {
|
|
164
165
|
const supportedParams = model.supported_parameters ?? [];
|
|
165
166
|
const hasReasoning =
|
|
@@ -168,12 +169,25 @@ async function buildPiConfig(
|
|
|
168
169
|
const supportsImages = inputModalities?.includes('image') ?? false;
|
|
169
170
|
|
|
170
171
|
// Fetch thinkingLevelMap from built-in registry if this is a reasoning model
|
|
171
|
-
const
|
|
172
|
+
const builtInThinkingLevelMap = hasReasoning
|
|
173
|
+
? await getBuiltInThinkingLevelMap(model.id)
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
// Fetch user override for this model
|
|
177
|
+
const userOverride = userOverrides ? getModelOverride(userOverrides, model.id) : undefined;
|
|
178
|
+
|
|
179
|
+
const thinkingLevelMap =
|
|
180
|
+
builtInThinkingLevelMap !== undefined || userOverride?.thinkingLevelMap !== undefined
|
|
181
|
+
? {
|
|
182
|
+
...builtInThinkingLevelMap,
|
|
183
|
+
...userOverride?.thinkingLevelMap,
|
|
184
|
+
}
|
|
185
|
+
: undefined;
|
|
172
186
|
|
|
173
187
|
const config: PiModelConfig = {
|
|
174
188
|
id: model.id,
|
|
175
189
|
name: model.name ?? model.id,
|
|
176
|
-
reasoning: hasReasoning,
|
|
190
|
+
reasoning: userOverride?.reasoning ?? hasReasoning,
|
|
177
191
|
input: supportsImages ? ['text', 'image'] : ['text'],
|
|
178
192
|
cost: {
|
|
179
193
|
input: Number(model.pricing.prompt) * COST_PER_MILLION,
|
|
@@ -181,8 +195,9 @@ async function buildPiConfig(
|
|
|
181
195
|
cacheRead: Number(model.pricing.input_cache_read ?? 0) * COST_PER_MILLION,
|
|
182
196
|
cacheWrite: Number(model.pricing.input_cache_write ?? 0) * COST_PER_MILLION,
|
|
183
197
|
},
|
|
184
|
-
contextWindow,
|
|
198
|
+
contextWindow: userOverride?.contextWindow ?? contextWindow,
|
|
185
199
|
maxTokens:
|
|
200
|
+
userOverride?.maxTokens ??
|
|
186
201
|
model.top_provider?.max_completion_tokens ??
|
|
187
202
|
model.per_request_limits?.completion_tokens ??
|
|
188
203
|
DEFAULT_MAX_TOKENS,
|
|
@@ -198,13 +213,14 @@ async function buildPiConfig(
|
|
|
198
213
|
|
|
199
214
|
/**
|
|
200
215
|
* Maps multiple OpenRouter models, tracking skips.
|
|
201
|
-
* Async to allow fetching thinkingLevelMap from built-in registry.
|
|
216
|
+
* Async to allow fetching thinkingLevelMap from built-in registry and user overrides.
|
|
202
217
|
*/
|
|
203
218
|
export async function mapOpenRouterModels(
|
|
204
219
|
models: OpenRouterModel[] | SDKModel[],
|
|
205
220
|
): Promise<MapResult> {
|
|
206
|
-
// Pre-load built-in models for efficient lookup during mapping
|
|
221
|
+
// Pre-load built-in models and user overrides for efficient lookup during mapping
|
|
207
222
|
await loadBuiltInOpenRouterModels();
|
|
223
|
+
const userOverrides = await loadModelOverrides();
|
|
208
224
|
|
|
209
225
|
const configs: PiModelConfig[] = [];
|
|
210
226
|
let skipped = 0;
|
|
@@ -226,7 +242,7 @@ export async function mapOpenRouterModels(
|
|
|
226
242
|
continue;
|
|
227
243
|
}
|
|
228
244
|
|
|
229
|
-
configs.push(await buildPiConfig(model, validation.contextWindow));
|
|
245
|
+
configs.push(await buildPiConfig(model, validation.contextWindow, userOverrides));
|
|
230
246
|
}
|
|
231
247
|
|
|
232
248
|
return { configs, skipped, skippedDetails };
|
|
@@ -241,6 +257,7 @@ export async function mapOpenRouterModel(
|
|
|
241
257
|
model: OpenRouterModel | SDKModel,
|
|
242
258
|
): Promise<PiModelConfig | null> {
|
|
243
259
|
await loadBuiltInOpenRouterModels();
|
|
260
|
+
const userOverrides = await loadModelOverrides();
|
|
244
261
|
|
|
245
262
|
const normalized = normalizeModel(model);
|
|
246
263
|
|
|
@@ -255,5 +272,5 @@ export async function mapOpenRouterModel(
|
|
|
255
272
|
return null;
|
|
256
273
|
}
|
|
257
274
|
|
|
258
|
-
return buildPiConfig(normalized, validation.contextWindow);
|
|
275
|
+
return buildPiConfig(normalized, validation.contextWindow, userOverrides);
|
|
259
276
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User model overrides persistence
|
|
3
|
+
*
|
|
4
|
+
* Manages ~/.pi/openrouter/model-overrides.json for user-defined
|
|
5
|
+
* PiModelConfig field overrides.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import type { UserModelOverride, ModelOverridesFile, ThinkingLevelMap } from './types.js';
|
|
13
|
+
|
|
14
|
+
export class ModelOverridesLoadError extends Error {
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'ModelOverridesLoadError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getErrorMessage(error: unknown): string {
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the path to the overrides file.
|
|
27
|
+
* Computed lazily to allow for test mocking.
|
|
28
|
+
*/
|
|
29
|
+
function getOverridesFile(): string {
|
|
30
|
+
return join(homedir(), '.pi', 'openrouter', 'model-overrides.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load model overrides from disk.
|
|
35
|
+
* Returns empty structure only when the file doesn't exist.
|
|
36
|
+
* Throws when an existing file cannot be read, parsed, or validated.
|
|
37
|
+
*/
|
|
38
|
+
export async function loadModelOverrides(): Promise<ModelOverridesFile> {
|
|
39
|
+
const overridesFile = getOverridesFile();
|
|
40
|
+
if (!existsSync(overridesFile)) {
|
|
41
|
+
return { version: 1, overrides: {} };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let content: string;
|
|
45
|
+
try {
|
|
46
|
+
content = await readFile(overridesFile, 'utf-8');
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new ModelOverridesLoadError(
|
|
49
|
+
`Failed to read model overrides file at ${overridesFile}: ${getErrorMessage(error)}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let data: unknown;
|
|
54
|
+
try {
|
|
55
|
+
data = JSON.parse(content) as unknown;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new ModelOverridesLoadError(
|
|
58
|
+
`Invalid JSON in model overrides file at ${overridesFile}: ${getErrorMessage(error)}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Basic validation
|
|
63
|
+
if (
|
|
64
|
+
typeof data === 'object' &&
|
|
65
|
+
data !== null &&
|
|
66
|
+
'version' in data &&
|
|
67
|
+
typeof (data as { version: unknown }).version === 'number' &&
|
|
68
|
+
'overrides' in data &&
|
|
69
|
+
typeof (data as { overrides: unknown }).overrides === 'object' &&
|
|
70
|
+
(data as { overrides: unknown }).overrides !== null
|
|
71
|
+
) {
|
|
72
|
+
return data as ModelOverridesFile;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new ModelOverridesLoadError(`Invalid model overrides file structure at ${overridesFile}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Save model overrides to disk.
|
|
80
|
+
*/
|
|
81
|
+
export async function saveModelOverrides(overrides: ModelOverridesFile): Promise<void> {
|
|
82
|
+
const overridesFile = getOverridesFile();
|
|
83
|
+
const overridesDir = join(homedir(), '.pi', 'openrouter');
|
|
84
|
+
|
|
85
|
+
// Ensure directory exists
|
|
86
|
+
if (!existsSync(overridesDir)) {
|
|
87
|
+
await mkdir(overridesDir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const content = JSON.stringify(overrides, null, 2);
|
|
91
|
+
await writeFile(overridesFile, content, 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get existing override for a specific model.
|
|
96
|
+
*/
|
|
97
|
+
export function getModelOverride(
|
|
98
|
+
overrides: ModelOverridesFile,
|
|
99
|
+
modelId: string,
|
|
100
|
+
): UserModelOverride | undefined {
|
|
101
|
+
return overrides.overrides[modelId];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set override for a specific model.
|
|
106
|
+
* Merges with existing override.
|
|
107
|
+
*/
|
|
108
|
+
export function setModelOverride(
|
|
109
|
+
overrides: ModelOverridesFile,
|
|
110
|
+
modelId: string,
|
|
111
|
+
override: UserModelOverride,
|
|
112
|
+
): ModelOverridesFile {
|
|
113
|
+
const existing = overrides.overrides[modelId] ?? {};
|
|
114
|
+
const mergedThinkingLevelMap =
|
|
115
|
+
existing.thinkingLevelMap !== undefined || override.thinkingLevelMap !== undefined
|
|
116
|
+
? {
|
|
117
|
+
...existing.thinkingLevelMap,
|
|
118
|
+
...override.thinkingLevelMap,
|
|
119
|
+
}
|
|
120
|
+
: undefined;
|
|
121
|
+
|
|
122
|
+
const merged: UserModelOverride = {
|
|
123
|
+
...existing,
|
|
124
|
+
...override,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (mergedThinkingLevelMap !== undefined) {
|
|
128
|
+
const cleaned = Object.fromEntries(
|
|
129
|
+
Object.entries(mergedThinkingLevelMap).filter(([, v]) => v !== undefined),
|
|
130
|
+
);
|
|
131
|
+
if (Object.keys(cleaned).length > 0) {
|
|
132
|
+
merged.thinkingLevelMap = cleaned as Partial<ThinkingLevelMap>;
|
|
133
|
+
} else {
|
|
134
|
+
delete merged.thinkingLevelMap;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...overrides,
|
|
140
|
+
overrides: {
|
|
141
|
+
...overrides.overrides,
|
|
142
|
+
[modelId]: merged,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove override for a specific model.
|
|
149
|
+
*/
|
|
150
|
+
export function removeModelOverride(
|
|
151
|
+
overrides: ModelOverridesFile,
|
|
152
|
+
modelId: string,
|
|
153
|
+
): ModelOverridesFile {
|
|
154
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
155
|
+
const { [modelId]: _, ...rest } = overrides.overrides;
|
|
156
|
+
return {
|
|
157
|
+
...overrides,
|
|
158
|
+
overrides: rest,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get all model IDs that have overrides.
|
|
164
|
+
*/
|
|
165
|
+
export function getOverrideModelIds(overrides: ModelOverridesFile): string[] {
|
|
166
|
+
return Object.keys(overrides.overrides);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if any overrides exist.
|
|
171
|
+
*/
|
|
172
|
+
export function hasOverrides(overrides: ModelOverridesFile): boolean {
|
|
173
|
+
return Object.keys(overrides.overrides).length > 0;
|
|
174
|
+
}
|
|
@@ -105,6 +105,35 @@ export interface MapResult {
|
|
|
105
105
|
skippedDetails: SkipReason[];
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// =============================================================================
|
|
109
|
+
// User Override Types
|
|
110
|
+
// =============================================================================
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* User-defined override for any PiModelConfig field.
|
|
114
|
+
* Stored in ~/.pi/openrouter/model-overrides.json
|
|
115
|
+
*
|
|
116
|
+
* For type safety, fields must be validated before storage.
|
|
117
|
+
* Unknown fields are ignored during merge.
|
|
118
|
+
*/
|
|
119
|
+
export interface UserModelOverride {
|
|
120
|
+
// Nested thinking level map - mapped from 'thinking.X' scoped syntax
|
|
121
|
+
thinkingLevelMap?: Partial<ThinkingLevelMap>;
|
|
122
|
+
|
|
123
|
+
// Top-level PiModelConfig overrides
|
|
124
|
+
contextWindow?: number;
|
|
125
|
+
maxTokens?: number;
|
|
126
|
+
reasoning?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The overrides file structure.
|
|
131
|
+
*/
|
|
132
|
+
export interface ModelOverridesFile {
|
|
133
|
+
version: number;
|
|
134
|
+
overrides: Record<string, UserModelOverride>;
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
// =============================================================================
|
|
109
138
|
// Built-in Router Definitions (Single Source of Truth)
|
|
110
139
|
// =============================================================================
|
package/package.json
CHANGED