@kdejaeger/pi-model-router 0.3.1

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,405 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { type Api, type Model } from '@earendil-works/pi-ai';
4
+ import { getAgentDir, type ExtensionContext } from '@earendil-works/pi-coding-agent';
5
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
6
+ import type {
7
+ RouterConfig,
8
+ RouterProfile,
9
+ RoutedTierConfig,
10
+ ConfigLoadResult,
11
+ ParsedConfigFile,
12
+ RouterTier,
13
+ } from './types';
14
+
15
+ export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
16
+
17
+ const THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
18
+ export const ROUTER_PIN_VALUES = ['clear', 'high', 'medium', 'low'] as const;
19
+
20
+ const isObjectRecord = (
21
+ value: unknown,
22
+ ): value is Record<string, unknown> =>
23
+ typeof value === 'object' && value !== null;
24
+
25
+ const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
26
+ typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
27
+
28
+ /**
29
+ * Returns true if value is a valid pin value.
30
+ * 'clear' unpins; 'high'|'medium'|'low' pins to that tier.
31
+ */
32
+ export const isPinValue = (value: string): value is (typeof ROUTER_PIN_VALUES)[number] =>
33
+ (ROUTER_PIN_VALUES as readonly string[]).includes(value);
34
+
35
+ const validateNonNegativeInt = (val: unknown, label: string, fallback: number | undefined, warnings: string[]): number | undefined => {
36
+ if (val === undefined || val === null) return fallback;
37
+ if (typeof val !== 'number' || !Number.isInteger(val) || val < 0) {
38
+ warnings.push(`Invalid ${label} (${JSON.stringify(val)}). Must be a non-negative integer.`);
39
+ return fallback;
40
+ }
41
+ return val;
42
+ };
43
+
44
+ const parseConfigFile = (path: string): ParsedConfigFile => {
45
+ if (!existsSync(path)) {
46
+ return { config: {}, warnings: [] };
47
+ }
48
+
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
51
+ if (!isObjectRecord(parsed)) {
52
+ return {
53
+ config: {},
54
+ warnings: [`Ignored router config at ${path}: expected a JSON object.`],
55
+ };
56
+ }
57
+ return { config: parsed as Partial<RouterConfig>, warnings: [] };
58
+ } catch (error) {
59
+ return {
60
+ config: {},
61
+ warnings: [
62
+ `Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
63
+ ],
64
+ };
65
+ }
66
+ };
67
+
68
+ const mergeTier = (
69
+ existing?: RoutedTierConfig,
70
+ next?: Partial<RoutedTierConfig>,
71
+ ): RoutedTierConfig | undefined => {
72
+ if (!next) return existing;
73
+ if (!existing) return next?.model ? (next as RoutedTierConfig) : undefined;
74
+ return { ...existing, ...next };
75
+ };
76
+
77
+ const mergeConfig = (
78
+ base: RouterConfig,
79
+ override: Partial<RouterConfig>,
80
+ ): RouterConfig => {
81
+ const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
82
+ for (const [name, profile] of Object.entries(override.profiles ?? {})) {
83
+ if (!isObjectRecord(profile)) continue;
84
+ const existing = mergedProfiles[name];
85
+ const nextProfile = profile as Partial<RouterProfile>;
86
+ mergedProfiles[name] = {
87
+ high: mergeTier(existing?.high, nextProfile.high),
88
+ medium: mergeTier(existing?.medium, nextProfile.medium),
89
+ low: mergeTier(existing?.low, nextProfile.low),
90
+ };
91
+ }
92
+ return {
93
+ debug: override.debug ?? base.debug,
94
+ classifierModels: override.classifierModels ?? base.classifierModels,
95
+ classifierModelThinking:
96
+ override.classifierModelThinking ?? base.classifierModelThinking,
97
+ classifierRunOnceAfterToolCount:
98
+ override.classifierRunOnceAfterToolCount ?? base.classifierRunOnceAfterToolCount,
99
+ classifierRunAfterToolFailures:
100
+ override.classifierRunAfterToolFailures ?? base.classifierRunAfterToolFailures,
101
+ classifierInterval:
102
+ override.classifierInterval ?? base.classifierInterval,
103
+ defaultContextThresholdPercent:
104
+ override.defaultContextThresholdPercent ?? base.defaultContextThresholdPercent,
105
+ contextThresholdPercentOverrides:
106
+ override.contextThresholdPercentOverrides ?? base.contextThresholdPercentOverrides,
107
+ profiles: mergedProfiles,
108
+ };
109
+ };
110
+
111
+ export const parseCanonicalModelRef = (
112
+ value: string,
113
+ ): { provider: string; modelId: string } => {
114
+ const slashIndex = value.indexOf('/');
115
+ if (slashIndex === -1) {
116
+ throw new Error(
117
+ `Invalid model reference "${value}". Expected "provider/model".`,
118
+ );
119
+ }
120
+ const provider = value.slice(0, slashIndex).trim();
121
+ const modelId = value.slice(slashIndex + 1).trim();
122
+ if (!provider || !modelId) {
123
+ throw new Error(
124
+ `Invalid model reference "${value}". Expected "provider/model".`,
125
+ );
126
+ }
127
+ return { provider, modelId };
128
+ };
129
+
130
+ /**
131
+ * Resolves a concrete model from a canonical reference string and a registry.
132
+ */
133
+ export const resolveModelFromRef = (
134
+ modelRef: string,
135
+ modelRegistry: ExtensionContext['modelRegistry'] | undefined,
136
+ ): Model<Api> | undefined => {
137
+ if (!modelRegistry) return undefined;
138
+ try {
139
+ const { provider, modelId } = parseCanonicalModelRef(modelRef);
140
+ return modelRegistry.find(provider, modelId);
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ };
145
+
146
+ const normalizeTierConfig = (
147
+ value: unknown,
148
+ profileName: string,
149
+ tier: RouterTier,
150
+ warnings: string[],
151
+ ): RoutedTierConfig | undefined => {
152
+ if (!isObjectRecord(value)) {
153
+ return undefined;
154
+ }
155
+
156
+ const model = typeof value.model === 'string' ? value.model.trim() : '';
157
+ if (!model) {
158
+ warnings.push(
159
+ `Profile "${profileName}" ${tier} tier is missing a model. Tier disabled.`,
160
+ );
161
+ return undefined;
162
+ }
163
+
164
+ try {
165
+ parseCanonicalModelRef(model);
166
+ } catch (error) {
167
+ warnings.push(
168
+ `Profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)} Tier disabled.`,
169
+ );
170
+ return undefined;
171
+ }
172
+
173
+ const thinking = isThinkingLevel(value.thinking)
174
+ ? value.thinking
175
+ : 'medium';
176
+ if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
177
+ warnings.push(
178
+ `Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`,
179
+ );
180
+ }
181
+
182
+ let fallbacks: string[] | undefined;
183
+ if (Array.isArray(value.fallbacks)) {
184
+ fallbacks = [];
185
+ for (const rawFB of value.fallbacks) {
186
+ if (typeof rawFB !== 'string') continue;
187
+ const trimmedFB = rawFB.trim();
188
+ if (!trimmedFB) continue;
189
+ try {
190
+ parseCanonicalModelRef(trimmedFB);
191
+ fallbacks.push(trimmedFB);
192
+ } catch (error) {
193
+ warnings.push(
194
+ `Invalid fallback model "${rawFB}" in profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)}`,
195
+ );
196
+ }
197
+ }
198
+ }
199
+
200
+ return { model, thinking, fallbacks };
201
+ };
202
+
203
+ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
204
+ const warnings: string[] = [];
205
+ const normalizedProfiles: Record<string, RouterProfile> = {};
206
+
207
+ for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
208
+ const trimmedName = name.trim();
209
+ if (!trimmedName) {
210
+ warnings.push('Ignored profile with empty name.');
211
+ continue;
212
+ }
213
+ if (trimmedName !== name) {
214
+ warnings.push(`Profile name "${name}" has leading/trailing whitespace. Using "${trimmedName}".`);
215
+ }
216
+ const high = normalizeTierConfig(
217
+ profile?.high,
218
+ trimmedName,
219
+ 'high',
220
+ warnings,
221
+ );
222
+ const medium = normalizeTierConfig(
223
+ profile?.medium,
224
+ trimmedName,
225
+ 'medium',
226
+ warnings,
227
+ );
228
+ const low = normalizeTierConfig(
229
+ profile?.low,
230
+ trimmedName,
231
+ 'low',
232
+ warnings,
233
+ );
234
+
235
+ if (!high && !medium && !low) {
236
+ warnings.push(
237
+ `Profile "${trimmedName}" has no valid tiers. Skipped.`,
238
+ );
239
+ continue;
240
+ }
241
+ if (!high) {
242
+ warnings.push(
243
+ `Profile "${trimmedName}" is missing the "high" tier. All three tiers (high, medium, low) are required. Skipped.`,
244
+ );
245
+ continue;
246
+ }
247
+ if (!medium) {
248
+ warnings.push(
249
+ `Profile "${trimmedName}" is missing the "medium" tier. All three tiers (high, medium, low) are required. Skipped.`,
250
+ );
251
+ continue;
252
+ }
253
+ if (!low) {
254
+ warnings.push(
255
+ `Profile "${trimmedName}" is missing the "low" tier. All three tiers (high, medium, low) are required. Skipped.`,
256
+ );
257
+ continue;
258
+ }
259
+
260
+ normalizedProfiles[trimmedName] = { high, medium, low };
261
+ }
262
+
263
+ if (Object.keys(normalizedProfiles).length === 0) {
264
+ warnings.push('No router profiles configured. Define at least one profile in your config.');
265
+ }
266
+
267
+ let defaultContextThresholdPercent = 90;
268
+ if (typeof raw.defaultContextThresholdPercent === 'number') {
269
+ if (raw.defaultContextThresholdPercent <= 0) {
270
+ warnings.push(
271
+ `defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) is not a positive number. Falling back to 90.`,
272
+ );
273
+ } else if (raw.defaultContextThresholdPercent > 100) {
274
+ warnings.push(
275
+ `defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) exceeds 100. Falling back to 90.`,
276
+ );
277
+ } else {
278
+ defaultContextThresholdPercent = raw.defaultContextThresholdPercent;
279
+ }
280
+ }
281
+
282
+ const contextThresholdPercentOverrides = isObjectRecord(raw.contextThresholdPercentOverrides)
283
+ ? Object.fromEntries(
284
+ Object.entries(raw.contextThresholdPercentOverrides).flatMap(([key, val]) => {
285
+ const trimmed = key.trim();
286
+ if (!trimmed) return [];
287
+ try {
288
+ parseCanonicalModelRef(trimmed);
289
+ } catch (error) {
290
+ warnings.push(`Ignored contextThresholdPercentOverride "${key}": invalid model reference — ${error instanceof Error ? error.message : String(error)}`);
291
+ return [];
292
+ }
293
+ if (typeof val !== 'number' || val <= 0) {
294
+ warnings.push(`Ignored contextThresholdPercentOverride "${key}" (${JSON.stringify(val)}): expected a positive number.`);
295
+ return [];
296
+ }
297
+ return [[trimmed, val] as [string, number]];
298
+ }),
299
+ )
300
+ : undefined;
301
+
302
+ let classifierModels: string[] | undefined = undefined;
303
+ if (Array.isArray(raw.classifierModels)) {
304
+ classifierModels = [];
305
+ for (const rawCM of raw.classifierModels) {
306
+ if (typeof rawCM === 'string') {
307
+ const trimmedCM = rawCM.trim();
308
+ if (!trimmedCM) continue;
309
+ try {
310
+ parseCanonicalModelRef(trimmedCM);
311
+ classifierModels.push(trimmedCM);
312
+ } catch (error) {
313
+ warnings.push(
314
+ `Invalid classifierModels entry "${rawCM}": ${error instanceof Error ? error.message : String(error)}`,
315
+ );
316
+ }
317
+ } else {
318
+ warnings.push(`Ignored non-string classifierModels entry: ${JSON.stringify(rawCM)}`);
319
+ }
320
+ }
321
+ if (classifierModels.length === 0) {
322
+ classifierModels = undefined;
323
+ }
324
+ }
325
+
326
+ const classifierModelThinking = isThinkingLevel(raw.classifierModelThinking) ? raw.classifierModelThinking : 'off';
327
+ if (raw.classifierModelThinking !== undefined && !isThinkingLevel(raw.classifierModelThinking)) {
328
+ warnings.push(`Invalid classifierModelThinking value "${raw.classifierModelThinking}". Falling back to "off".`);
329
+ }
330
+
331
+ const classifierRunOnceAfterToolCount = validateNonNegativeInt(raw.classifierRunOnceAfterToolCount, 'classifierRunOnceAfterToolCount', 3, warnings);
332
+ const classifierRunAfterToolFailures = validateNonNegativeInt(raw.classifierRunAfterToolFailures, 'classifierRunAfterToolFailures', 2, warnings);
333
+ const classifierInterval = validateNonNegativeInt(raw.classifierInterval, 'classifierInterval', 10, warnings);
334
+
335
+ return {
336
+ config: {
337
+ debug: typeof raw.debug === 'boolean' ? raw.debug : false,
338
+ classifierModels,
339
+ classifierModelThinking,
340
+ classifierRunOnceAfterToolCount,
341
+ classifierRunAfterToolFailures,
342
+ classifierInterval,
343
+ defaultContextThresholdPercent,
344
+ contextThresholdPercentOverrides,
345
+ profiles: normalizedProfiles,
346
+ },
347
+ warnings,
348
+ };
349
+ };
350
+
351
+ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
352
+ const globalPath = join(getAgentDir(), 'model-router.json');
353
+ const projectPath = join(cwd, '.pi', 'model-router.json');
354
+ const globalResult = parseConfigFile(globalPath);
355
+ const projectResult = parseConfigFile(projectPath);
356
+ const baseConfig: RouterConfig = { profiles: {} };
357
+ const merged = mergeConfig(
358
+ mergeConfig(baseConfig, globalResult.config),
359
+ projectResult.config,
360
+ );
361
+ const normalized = normalizeConfig(merged);
362
+ return {
363
+ config: normalized.config,
364
+ warnings: [
365
+ ...globalResult.warnings,
366
+ ...projectResult.warnings,
367
+ ...normalized.warnings,
368
+ ],
369
+ };
370
+ };
371
+
372
+ export const profileNames = (config: RouterConfig): string[] => {
373
+ return Object.keys(config.profiles).sort();
374
+ };
375
+
376
+ /**
377
+ * OpenRouter attribution headers required for API usage tracking.
378
+ * These identify the app to OpenRouter's analytics.
379
+ */
380
+ export const OPENROUTER_ATTR_HEADERS: Readonly<Record<string, string>> = {
381
+ 'HTTP-Referer': 'https://pi.dev',
382
+ 'X-OpenRouter-Title': 'pi',
383
+ 'X-OpenRouter-Categories': 'cli-agent',
384
+ };
385
+
386
+ /** Create an onPayload handler that injects session_id for OpenRouter session tracking. */
387
+ export const createOpenRouterOnPayload = (
388
+ sessionProvider?: { getSessionId(): string; getSessionName(): string | undefined },
389
+ origOnPayload?: (p: any, m: any) => any,
390
+ ): ((p: any, m: any) => Promise<any>) | undefined => {
391
+ const rawId = sessionProvider?.getSessionId();
392
+ const name = sessionProvider?.getSessionName();
393
+ const sessionId = name && rawId ? `${name.replace(/\s+/g, '-')}-${rawId.slice(0, 8)}` : rawId;
394
+ if (!sessionId) return undefined;
395
+ return async (p: any, m: any) => {
396
+ const payload = origOnPayload ? await origOnPayload(p, m) : p;
397
+ return { ...payload, session_id: sessionId };
398
+ };
399
+ };
400
+
401
+ export const resolveProfileName = (
402
+ config: RouterConfig,
403
+ requested?: string,
404
+ ): string | undefined =>
405
+ requested && config.profiles[requested] ? requested : undefined;