@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.
@@ -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 if available.
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 thinkingLevelMap = hasReasoning ? await getBuiltInThinkingLevelMap(model.id) : undefined;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Live OpenRouter spend/account TUI overlays, user-scoped model sync, and session tagging for Pi.",
6
6
  "license": "MIT",