@alexeiled/pi-model-router 0.5.1 → 0.6.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/CHANGELOG.md +21 -0
- package/README.md +145 -24
- package/extensions/classifier.ts +119 -0
- package/extensions/commands.ts +76 -40
- package/extensions/config.ts +240 -146
- package/extensions/context.ts +91 -0
- package/extensions/index.ts +120 -132
- package/extensions/jev.ts +223 -0
- package/extensions/provider.ts +392 -178
- package/extensions/routing.ts +233 -436
- package/extensions/state.ts +74 -25
- package/extensions/types.ts +148 -52
- package/extensions/ui.ts +32 -34
- package/model-router.example.json +17 -10
- package/package.json +4 -4
package/extensions/config.ts
CHANGED
|
@@ -7,19 +7,22 @@ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
|
|
|
7
7
|
import type {
|
|
8
8
|
ClassifierConfig,
|
|
9
9
|
ConfigLoadResult,
|
|
10
|
+
JevConfig,
|
|
10
11
|
ModelDefinition,
|
|
11
12
|
ParsedConfigFile,
|
|
13
|
+
RawRouterConfig,
|
|
12
14
|
RoutedTierConfig,
|
|
13
15
|
RouterConfig,
|
|
14
16
|
RouterProfile,
|
|
15
17
|
RouterTier,
|
|
16
|
-
RoutingRule,
|
|
17
18
|
} from './types';
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
import { ROUTER_TIERS } from './types';
|
|
21
|
+
|
|
22
|
+
export { ROUTER_TIERS } from './types';
|
|
20
23
|
|
|
21
24
|
// Pi accepts this model capability at runtime, but older peer type releases omit it.
|
|
22
|
-
export const MAX_THINKING_LEVEL = 'max'
|
|
25
|
+
export const MAX_THINKING_LEVEL: ThinkingLevel = 'max';
|
|
23
26
|
|
|
24
27
|
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
25
28
|
'off',
|
|
@@ -30,7 +33,10 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
|
30
33
|
'xhigh',
|
|
31
34
|
MAX_THINKING_LEVEL,
|
|
32
35
|
];
|
|
33
|
-
export const ROUTER_PIN_VALUES = ['auto',
|
|
36
|
+
export const ROUTER_PIN_VALUES = ['auto', ...ROUTER_TIERS] as const;
|
|
37
|
+
export type RouterPinValue = (typeof ROUTER_PIN_VALUES)[number];
|
|
38
|
+
export const isRouterPinValue = (value: unknown): value is RouterPinValue =>
|
|
39
|
+
ROUTER_PIN_VALUES.some((candidate) => candidate === value);
|
|
34
40
|
|
|
35
41
|
export const DEFAULT_THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
36
42
|
'high',
|
|
@@ -44,10 +50,10 @@ export const isObjectRecord = (
|
|
|
44
50
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
45
51
|
|
|
46
52
|
export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
|
|
47
|
-
typeof value === 'string' && THINKING_LEVELS.
|
|
53
|
+
typeof value === 'string' && THINKING_LEVELS.some((level) => level === value);
|
|
48
54
|
|
|
49
55
|
export const isRouterTier = (value: unknown): value is RouterTier =>
|
|
50
|
-
|
|
56
|
+
ROUTER_TIERS.some((tier) => tier === value);
|
|
51
57
|
|
|
52
58
|
export const parseConfigFile = (path: string): ParsedConfigFile => {
|
|
53
59
|
if (!existsSync(path)) {
|
|
@@ -55,20 +61,19 @@ export const parseConfigFile = (path: string): ParsedConfigFile => {
|
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
try {
|
|
58
|
-
const parsed = JSON.parse(readFileSync(path, 'utf-8'))
|
|
64
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'));
|
|
59
65
|
if (!isObjectRecord(parsed)) {
|
|
60
66
|
return {
|
|
61
67
|
config: {},
|
|
62
68
|
warnings: [`Ignored router config at ${path}: expected a JSON object.`],
|
|
63
69
|
};
|
|
64
70
|
}
|
|
65
|
-
return { config: parsed
|
|
66
|
-
} catch
|
|
71
|
+
return { config: parsed, warnings: [] };
|
|
72
|
+
} catch {
|
|
73
|
+
// JSON parse errors can include source snippets containing credentials.
|
|
67
74
|
return {
|
|
68
75
|
config: {},
|
|
69
|
-
warnings: [
|
|
70
|
-
`Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
71
|
-
],
|
|
76
|
+
warnings: [`Failed to parse router config at ${path}.`],
|
|
72
77
|
};
|
|
73
78
|
}
|
|
74
79
|
};
|
|
@@ -90,42 +95,48 @@ export const resolveModelRef = (
|
|
|
90
95
|
return { canonicalRef: ref };
|
|
91
96
|
};
|
|
92
97
|
|
|
93
|
-
const
|
|
94
|
-
existing
|
|
95
|
-
next
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!existing) return next as RoutedTierConfig;
|
|
100
|
-
return { ...existing, ...next };
|
|
98
|
+
const mergeRawValue = (existing: unknown, next: unknown): unknown => {
|
|
99
|
+
if (next === undefined) return existing;
|
|
100
|
+
if (isObjectRecord(existing) && isObjectRecord(next)) {
|
|
101
|
+
return { ...existing, ...next };
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
101
104
|
};
|
|
102
105
|
|
|
103
106
|
export const mergeConfig = (
|
|
104
|
-
base:
|
|
105
|
-
override:
|
|
106
|
-
):
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
base: RawRouterConfig,
|
|
108
|
+
override: RawRouterConfig,
|
|
109
|
+
): RawRouterConfig => {
|
|
110
|
+
const baseProfiles = isObjectRecord(base.profiles) ? base.profiles : {};
|
|
111
|
+
const overrideProfiles = isObjectRecord(override.profiles)
|
|
112
|
+
? override.profiles
|
|
113
|
+
: {};
|
|
114
|
+
const mergedProfiles: Record<string, unknown> = { ...baseProfiles };
|
|
115
|
+
for (const [name, profile] of Object.entries(overrideProfiles)) {
|
|
116
|
+
if (name === '__proto__') continue;
|
|
117
|
+
if (!isObjectRecord(profile)) {
|
|
118
|
+
mergedProfiles[name] = profile;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const existing = isObjectRecord(mergedProfiles[name])
|
|
113
122
|
? mergedProfiles[name]
|
|
114
|
-
:
|
|
115
|
-
const nextProfile = profile as Partial<RouterProfile>;
|
|
123
|
+
: {};
|
|
116
124
|
mergedProfiles[name] = {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
baselineTier: mergeRawValue(existing.baselineTier, profile.baselineTier),
|
|
126
|
+
high: mergeRawValue(existing.high, profile.high),
|
|
127
|
+
medium: mergeRawValue(existing.medium, profile.medium),
|
|
128
|
+
low: mergeRawValue(existing.low, profile.low),
|
|
129
|
+
micro: mergeRawValue(existing.micro, profile.micro),
|
|
130
|
+
jev: mergeRawValue(existing.jev, profile.jev),
|
|
120
131
|
};
|
|
121
132
|
}
|
|
122
133
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
134
|
+
const baseModels = isObjectRecord(base.models) ? base.models : {};
|
|
135
|
+
const overrideModels = isObjectRecord(override.models) ? override.models : {};
|
|
136
|
+
const mergedModels = { ...baseModels, ...overrideModels };
|
|
127
137
|
|
|
128
138
|
return {
|
|
139
|
+
jev: mergeRawValue(base.jev, override.jev),
|
|
129
140
|
debug: override.debug ?? base.debug,
|
|
130
141
|
classifierModel: override.classifierModel ?? base.classifierModel,
|
|
131
142
|
phaseBias: override.phaseBias ?? base.phaseBias,
|
|
@@ -141,16 +152,12 @@ export const parseCanonicalModelRef = (
|
|
|
141
152
|
): { provider: string; modelId: string } => {
|
|
142
153
|
const slashIndex = value.indexOf('/');
|
|
143
154
|
if (slashIndex === -1) {
|
|
144
|
-
throw new Error(
|
|
145
|
-
`Invalid model reference "${value}". Expected "provider/model".`,
|
|
146
|
-
);
|
|
155
|
+
throw new Error('Invalid model reference. Expected "provider/model".');
|
|
147
156
|
}
|
|
148
157
|
const provider = value.slice(0, slashIndex).trim();
|
|
149
158
|
const modelId = value.slice(slashIndex + 1).trim();
|
|
150
159
|
if (!provider || !modelId) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`Invalid model reference "${value}". Expected "provider/model".`,
|
|
153
|
-
);
|
|
160
|
+
throw new Error('Invalid model reference. Expected "provider/model".');
|
|
154
161
|
}
|
|
155
162
|
return { provider, modelId };
|
|
156
163
|
};
|
|
@@ -159,7 +166,7 @@ export const parseCanonicalModelRef = (
|
|
|
159
166
|
* Validate and normalize the models map from config.
|
|
160
167
|
*/
|
|
161
168
|
export const normalizeModelsMap = (
|
|
162
|
-
raw:
|
|
169
|
+
raw: unknown,
|
|
163
170
|
warnings: string[],
|
|
164
171
|
): Record<string, ModelDefinition> => {
|
|
165
172
|
const result: Record<string, ModelDefinition> = {};
|
|
@@ -174,7 +181,7 @@ export const normalizeModelsMap = (
|
|
|
174
181
|
continue;
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
|
|
184
|
+
let model = typeof entry.model === 'string' ? entry.model.trim() : '';
|
|
178
185
|
if (!model) {
|
|
179
186
|
warnings.push(
|
|
180
187
|
`Model definition "${alias}" is missing the "model" field. Skipped.`,
|
|
@@ -183,10 +190,11 @@ export const normalizeModelsMap = (
|
|
|
183
190
|
}
|
|
184
191
|
|
|
185
192
|
try {
|
|
186
|
-
parseCanonicalModelRef(model);
|
|
187
|
-
|
|
193
|
+
const { provider, modelId } = parseCanonicalModelRef(model);
|
|
194
|
+
model = `${provider}/${modelId}`;
|
|
195
|
+
} catch {
|
|
188
196
|
warnings.push(
|
|
189
|
-
`Model definition "${alias}"
|
|
197
|
+
`Model definition "${alias}" has an invalid model reference. Skipped.`,
|
|
190
198
|
);
|
|
191
199
|
continue;
|
|
192
200
|
}
|
|
@@ -259,23 +267,31 @@ export const normalizeTierConfig = (
|
|
|
259
267
|
const aliasDefinition = resolved.definition;
|
|
260
268
|
let parsedModel: string;
|
|
261
269
|
try {
|
|
262
|
-
parseCanonicalModelRef(resolved.canonicalRef);
|
|
263
|
-
parsedModel =
|
|
264
|
-
} catch
|
|
270
|
+
const { provider, modelId } = parseCanonicalModelRef(resolved.canonicalRef);
|
|
271
|
+
parsedModel = `${provider}/${modelId}`;
|
|
272
|
+
} catch {
|
|
265
273
|
warnings.push(
|
|
266
|
-
`Profile "${profileName}" ${tier} tier
|
|
274
|
+
`Profile "${profileName}" ${tier} tier has an invalid model reference. Tier disabled.`,
|
|
267
275
|
);
|
|
268
276
|
return undefined;
|
|
269
277
|
}
|
|
270
278
|
|
|
271
|
-
const
|
|
279
|
+
const tierReasoning =
|
|
280
|
+
typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
|
|
281
|
+
const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
|
|
282
|
+
const defaultThinking =
|
|
283
|
+
tier === 'micro' || effectiveReasoning === false ? 'off' : 'medium';
|
|
284
|
+
const thinking = isThinkingLevel(value.thinking)
|
|
285
|
+
? value.thinking
|
|
286
|
+
: defaultThinking;
|
|
272
287
|
if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
|
|
273
288
|
warnings.push(
|
|
274
|
-
`Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to
|
|
289
|
+
`Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to ${defaultThinking}.`,
|
|
275
290
|
);
|
|
276
291
|
}
|
|
277
292
|
|
|
278
293
|
let fallbacks: string[] | undefined;
|
|
294
|
+
const resolvedFallbacks: ModelDefinition[] = [];
|
|
279
295
|
if (Array.isArray(value.fallbacks)) {
|
|
280
296
|
fallbacks = [];
|
|
281
297
|
for (const f of value.fallbacks) {
|
|
@@ -283,11 +299,15 @@ export const normalizeTierConfig = (
|
|
|
283
299
|
// Resolve aliases in fallbacks too
|
|
284
300
|
const resolvedFallback = resolveModelRef(f, models);
|
|
285
301
|
try {
|
|
286
|
-
parseCanonicalModelRef(
|
|
287
|
-
|
|
288
|
-
|
|
302
|
+
const { provider, modelId } = parseCanonicalModelRef(
|
|
303
|
+
resolvedFallback.canonicalRef,
|
|
304
|
+
);
|
|
305
|
+
const model = `${provider}/${modelId}`;
|
|
306
|
+
fallbacks.push(model);
|
|
307
|
+
resolvedFallbacks.push({ ...resolvedFallback.definition, model });
|
|
308
|
+
} catch {
|
|
289
309
|
warnings.push(
|
|
290
|
-
`Invalid fallback model
|
|
310
|
+
`Invalid fallback model in profile "${profileName}" ${tier} tier. Ignored.`,
|
|
291
311
|
);
|
|
292
312
|
}
|
|
293
313
|
}
|
|
@@ -312,17 +332,12 @@ export const normalizeTierConfig = (
|
|
|
312
332
|
const resolvedMaxTokens =
|
|
313
333
|
tierMaxTokens ?? aliasDefinition?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
314
334
|
|
|
315
|
-
// Resolve reasoning: tier config > alias > undefined (assumed true)
|
|
316
|
-
const tierReasoning =
|
|
317
|
-
typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
|
|
318
|
-
const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
|
|
319
|
-
|
|
320
335
|
// Resolve thinkingLevels: tier config > alias > default
|
|
321
336
|
// Validate tier-level thinkingLevels array
|
|
322
337
|
let tierThinkingLevels: ThinkingLevel[] | undefined;
|
|
323
338
|
if (Array.isArray(value.thinkingLevels)) {
|
|
324
|
-
tierThinkingLevels =
|
|
325
|
-
|
|
339
|
+
tierThinkingLevels = value.thinkingLevels.filter((l): l is ThinkingLevel =>
|
|
340
|
+
isThinkingLevel(l),
|
|
326
341
|
);
|
|
327
342
|
if (tierThinkingLevels.length === 0) tierThinkingLevels = undefined;
|
|
328
343
|
}
|
|
@@ -347,11 +362,13 @@ export const normalizeTierConfig = (
|
|
|
347
362
|
|
|
348
363
|
return {
|
|
349
364
|
model: parsedModel,
|
|
365
|
+
thinkingExplicit: isThinkingLevel(value.thinking),
|
|
350
366
|
thinking,
|
|
351
367
|
fallbacks,
|
|
368
|
+
resolvedFallbacks,
|
|
352
369
|
contextWindow: tierContextWindow,
|
|
353
370
|
maxTokens: tierMaxTokens,
|
|
354
|
-
reasoning:
|
|
371
|
+
reasoning: effectiveReasoning,
|
|
355
372
|
thinkingLevels: tierThinkingLevels,
|
|
356
373
|
resolvedContextWindow,
|
|
357
374
|
resolvedMaxTokens,
|
|
@@ -359,14 +376,113 @@ export const normalizeTierConfig = (
|
|
|
359
376
|
};
|
|
360
377
|
};
|
|
361
378
|
|
|
362
|
-
export const
|
|
379
|
+
export const DEFAULT_JEV_CONFIG = {
|
|
380
|
+
endpoint: 'https://api.typesafe.ai/v1/systemone',
|
|
381
|
+
model: 'jev-1.13.0',
|
|
382
|
+
timeoutMs: 750,
|
|
383
|
+
confidenceThreshold: 0.65,
|
|
384
|
+
maxStateChars: 12000,
|
|
385
|
+
mode: 'advisory',
|
|
386
|
+
} as const;
|
|
387
|
+
|
|
388
|
+
export const isJevEndpoint = (value: unknown): value is string => {
|
|
389
|
+
if (typeof value !== 'string') return false;
|
|
390
|
+
try {
|
|
391
|
+
const url = new URL(value);
|
|
392
|
+
return (
|
|
393
|
+
url.protocol === 'https:' &&
|
|
394
|
+
!url.username &&
|
|
395
|
+
!url.password &&
|
|
396
|
+
!url.search &&
|
|
397
|
+
!url.hash
|
|
398
|
+
);
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
export const normalizeJevConfig = (
|
|
405
|
+
raw: unknown,
|
|
406
|
+
warnings: string[],
|
|
407
|
+
): JevConfig | undefined => {
|
|
408
|
+
if (raw === undefined) return undefined;
|
|
409
|
+
const invalid = (): undefined => {
|
|
410
|
+
warnings.push('Ignored invalid Jev configuration.');
|
|
411
|
+
return undefined;
|
|
412
|
+
};
|
|
413
|
+
if (!isObjectRecord(raw)) return invalid();
|
|
414
|
+
const value: Record<string, unknown> = { ...DEFAULT_JEV_CONFIG, ...raw };
|
|
415
|
+
if (
|
|
416
|
+
(value.enabled !== undefined && typeof value.enabled !== 'boolean') ||
|
|
417
|
+
!isJevEndpoint(value.endpoint) ||
|
|
418
|
+
typeof value.model !== 'string' ||
|
|
419
|
+
!/^[a-zA-Z0-9._-]{1,128}$/.test(value.model) ||
|
|
420
|
+
typeof value.timeoutMs !== 'number' ||
|
|
421
|
+
!Number.isFinite(value.timeoutMs) ||
|
|
422
|
+
value.timeoutMs <= 0 ||
|
|
423
|
+
value.timeoutMs > 1500 ||
|
|
424
|
+
typeof value.confidenceThreshold !== 'number' ||
|
|
425
|
+
!Number.isFinite(value.confidenceThreshold) ||
|
|
426
|
+
value.confidenceThreshold < 0 ||
|
|
427
|
+
value.confidenceThreshold > 1 ||
|
|
428
|
+
typeof value.maxStateChars !== 'number' ||
|
|
429
|
+
!Number.isInteger(value.maxStateChars) ||
|
|
430
|
+
value.maxStateChars < 1 ||
|
|
431
|
+
value.maxStateChars > 12000 ||
|
|
432
|
+
value.mode !== 'advisory' ||
|
|
433
|
+
(value.apiKey !== undefined &&
|
|
434
|
+
(typeof value.apiKey !== 'string' || /[\r\n]/.test(value.apiKey)))
|
|
435
|
+
)
|
|
436
|
+
return invalid();
|
|
437
|
+
if (value.timeoutMs > 750)
|
|
438
|
+
warnings.push(
|
|
439
|
+
'Jev timeoutMs clamped to the effective 750 ms provider cap.',
|
|
440
|
+
);
|
|
441
|
+
const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : '';
|
|
442
|
+
if (value.enabled === true && !apiKey) {
|
|
443
|
+
warnings.push('Jev disabled: missing user-config API key.');
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
enabled: value.enabled === true && apiKey.length > 0,
|
|
447
|
+
apiKey,
|
|
448
|
+
endpoint: value.endpoint,
|
|
449
|
+
model: value.model,
|
|
450
|
+
timeoutMs: Math.min(value.timeoutMs, 750),
|
|
451
|
+
confidenceThreshold: value.confidenceThreshold,
|
|
452
|
+
maxStateChars: value.maxStateChars,
|
|
453
|
+
mode: 'advisory',
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// Remove every project Jev setting before merging with user-owned credentials.
|
|
458
|
+
export const stripProjectJevConfig = (
|
|
459
|
+
raw: RawRouterConfig,
|
|
460
|
+
warnings: string[],
|
|
461
|
+
): RawRouterConfig => {
|
|
462
|
+
const { jev: ignored, ...project } = raw;
|
|
463
|
+
let found = ignored !== undefined;
|
|
464
|
+
if (isObjectRecord(project.profiles)) {
|
|
465
|
+
project.profiles = Object.fromEntries(
|
|
466
|
+
Object.entries(project.profiles).map(([name, profile]) => {
|
|
467
|
+
if (!isObjectRecord(profile)) return [name, profile];
|
|
468
|
+
const { jev, ...tiers } = profile;
|
|
469
|
+
if (jev !== undefined) found = true;
|
|
470
|
+
return [name, tiers];
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (found)
|
|
475
|
+
warnings.push(
|
|
476
|
+
'Ignored project Jev settings: configure Jev only in user config.',
|
|
477
|
+
);
|
|
478
|
+
return project;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
363
482
|
const warnings: string[] = [];
|
|
364
483
|
|
|
365
484
|
// Normalize models map first so aliases are available during tier normalization
|
|
366
|
-
const normalizedModels = normalizeModelsMap(
|
|
367
|
-
raw.models as Record<string, unknown> | undefined,
|
|
368
|
-
warnings,
|
|
369
|
-
);
|
|
485
|
+
const normalizedModels = normalizeModelsMap(raw.models, warnings);
|
|
370
486
|
const hasModels = Object.keys(normalizedModels).length > 0;
|
|
371
487
|
|
|
372
488
|
const normalizedProfiles: Record<string, RouterProfile> = {};
|
|
@@ -375,78 +491,83 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
375
491
|
isObjectRecord(raw.profiles) ? raw.profiles : {},
|
|
376
492
|
)) {
|
|
377
493
|
if (name === '__proto__') continue;
|
|
494
|
+
const profileRecord = isObjectRecord(profile) ? profile : {};
|
|
378
495
|
const high = normalizeTierConfig(
|
|
379
|
-
|
|
496
|
+
profileRecord.high,
|
|
380
497
|
name,
|
|
381
498
|
'high',
|
|
382
499
|
warnings,
|
|
383
500
|
hasModels ? normalizedModels : undefined,
|
|
384
501
|
);
|
|
385
502
|
const medium = normalizeTierConfig(
|
|
386
|
-
|
|
503
|
+
profileRecord.medium,
|
|
387
504
|
name,
|
|
388
505
|
'medium',
|
|
389
506
|
warnings,
|
|
390
507
|
hasModels ? normalizedModels : undefined,
|
|
391
508
|
);
|
|
392
509
|
const low = normalizeTierConfig(
|
|
393
|
-
|
|
510
|
+
profileRecord.low,
|
|
394
511
|
name,
|
|
395
512
|
'low',
|
|
396
513
|
warnings,
|
|
397
514
|
hasModels ? normalizedModels : undefined,
|
|
398
515
|
);
|
|
399
516
|
|
|
400
|
-
|
|
517
|
+
const micro = normalizeTierConfig(
|
|
518
|
+
profileRecord.micro,
|
|
519
|
+
name,
|
|
520
|
+
'micro',
|
|
521
|
+
warnings,
|
|
522
|
+
hasModels ? normalizedModels : undefined,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
if (!high && !medium && !low && !micro) {
|
|
401
526
|
warnings.push(`Profile "${name}" has no valid tiers. Skipped.`);
|
|
402
527
|
continue;
|
|
403
528
|
}
|
|
404
529
|
|
|
405
|
-
|
|
530
|
+
let baselineTier: RouterTier | undefined;
|
|
531
|
+
if (profileRecord.baselineTier !== undefined) {
|
|
532
|
+
const candidate = profileRecord.baselineTier;
|
|
533
|
+
const normalizedTier = isRouterTier(candidate)
|
|
534
|
+
? { high, medium, low, micro }[candidate]
|
|
535
|
+
: undefined;
|
|
536
|
+
if (isRouterTier(candidate) && normalizedTier) {
|
|
537
|
+
baselineTier = candidate;
|
|
538
|
+
} else {
|
|
539
|
+
warnings.push(
|
|
540
|
+
`Profile "${name}" baselineTier must name a configured tier. Ignored.`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const jev = isObjectRecord(profileRecord.jev)
|
|
546
|
+
? { enabled: profileRecord.jev.enabled === true }
|
|
547
|
+
: undefined;
|
|
548
|
+
normalizedProfiles[name] = {
|
|
549
|
+
...(baselineTier ? { baselineTier } : {}),
|
|
550
|
+
high,
|
|
551
|
+
medium,
|
|
552
|
+
low,
|
|
553
|
+
micro,
|
|
554
|
+
jev,
|
|
555
|
+
};
|
|
406
556
|
}
|
|
407
557
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
558
|
+
if (raw.phaseBias !== undefined)
|
|
559
|
+
warnings.push('Deprecated router config field "phaseBias" ignored.');
|
|
560
|
+
if (raw.rules !== undefined)
|
|
561
|
+
warnings.push('Deprecated router config field "rules" ignored.');
|
|
412
562
|
|
|
413
563
|
const maxSessionBudget =
|
|
414
564
|
typeof raw.maxSessionBudget === 'number' && raw.maxSessionBudget > 0
|
|
415
565
|
? raw.maxSessionBudget
|
|
416
566
|
: undefined;
|
|
417
567
|
|
|
418
|
-
const rules: RoutingRule[] = [];
|
|
419
|
-
if (Array.isArray(raw.rules)) {
|
|
420
|
-
for (const rule of raw.rules) {
|
|
421
|
-
if (isObjectRecord(rule)) {
|
|
422
|
-
const matches = rule.matches;
|
|
423
|
-
const tier = rule.tier;
|
|
424
|
-
if (
|
|
425
|
-
((typeof matches === 'string' && matches.trim().length > 0) ||
|
|
426
|
-
(Array.isArray(matches) &&
|
|
427
|
-
matches.length > 0 &&
|
|
428
|
-
matches.every(
|
|
429
|
-
(m) => typeof m === 'string' && m.trim().length > 0,
|
|
430
|
-
))) &&
|
|
431
|
-
isRouterTier(tier)
|
|
432
|
-
) {
|
|
433
|
-
rules.push({
|
|
434
|
-
matches,
|
|
435
|
-
tier,
|
|
436
|
-
reason: typeof rule.reason === 'string' ? rule.reason : undefined,
|
|
437
|
-
});
|
|
438
|
-
} else {
|
|
439
|
-
warnings.push(
|
|
440
|
-
`Ignored invalid routing rule: ${JSON.stringify(rule)}`,
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
568
|
// Resolve classifierModel — accepts string or { model, thinking } object
|
|
448
569
|
let classifierModel: ClassifierConfig | undefined;
|
|
449
|
-
const rawClassifier = raw.classifierModel
|
|
570
|
+
const rawClassifier = raw.classifierModel;
|
|
450
571
|
if (typeof rawClassifier === 'string' && rawClassifier.trim()) {
|
|
451
572
|
const resolved = resolveModelRef(
|
|
452
573
|
rawClassifier.trim(),
|
|
@@ -455,10 +576,8 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
455
576
|
try {
|
|
456
577
|
parseCanonicalModelRef(resolved.canonicalRef);
|
|
457
578
|
classifierModel = { model: resolved.canonicalRef };
|
|
458
|
-
} catch
|
|
459
|
-
warnings.push(
|
|
460
|
-
`Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
|
|
461
|
-
);
|
|
579
|
+
} catch {
|
|
580
|
+
warnings.push('Invalid classifierModel model reference. Ignored.');
|
|
462
581
|
}
|
|
463
582
|
} else if (isObjectRecord(rawClassifier)) {
|
|
464
583
|
const modelRef =
|
|
@@ -475,14 +594,12 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
475
594
|
: undefined;
|
|
476
595
|
if (rawClassifier.thinking !== undefined && !thinking) {
|
|
477
596
|
warnings.push(
|
|
478
|
-
|
|
597
|
+
'classifierModel has an invalid thinking level. Ignored.',
|
|
479
598
|
);
|
|
480
599
|
}
|
|
481
600
|
classifierModel = { model: resolved.canonicalRef, thinking };
|
|
482
|
-
} catch
|
|
483
|
-
warnings.push(
|
|
484
|
-
`Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
|
|
485
|
-
);
|
|
601
|
+
} catch {
|
|
602
|
+
warnings.push('Invalid classifierModel model reference. Ignored.');
|
|
486
603
|
}
|
|
487
604
|
} else {
|
|
488
605
|
warnings.push(
|
|
@@ -493,11 +610,10 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
493
610
|
|
|
494
611
|
return {
|
|
495
612
|
config: {
|
|
613
|
+
jev: normalizeJevConfig(raw.jev, warnings),
|
|
496
614
|
debug: typeof raw.debug === 'boolean' ? raw.debug : false,
|
|
497
615
|
classifierModel,
|
|
498
|
-
phaseBias,
|
|
499
616
|
maxSessionBudget,
|
|
500
|
-
rules: rules.length > 0 ? rules : undefined,
|
|
501
617
|
profiles: normalizedProfiles,
|
|
502
618
|
models: hasModels ? normalizedModels : undefined,
|
|
503
619
|
},
|
|
@@ -510,10 +626,10 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
|
|
|
510
626
|
const projectPath = join(cwd, '.pi', 'model-router.json');
|
|
511
627
|
const globalResult = parseConfigFile(globalPath);
|
|
512
628
|
const projectResult = parseConfigFile(projectPath);
|
|
513
|
-
const baseConfig:
|
|
629
|
+
const baseConfig: RawRouterConfig = { profiles: {} };
|
|
514
630
|
const merged = mergeConfig(
|
|
515
631
|
mergeConfig(baseConfig, globalResult.config),
|
|
516
|
-
projectResult.config,
|
|
632
|
+
stripProjectJevConfig(projectResult.config, projectResult.warnings),
|
|
517
633
|
);
|
|
518
634
|
const normalized = normalizeConfig(merged);
|
|
519
635
|
return {
|
|
@@ -634,25 +750,3 @@ export const getUnsupportedTiers = (
|
|
|
634
750
|
}
|
|
635
751
|
return unsupported;
|
|
636
752
|
};
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* Clamps a requested thinking level to the highest supported level
|
|
640
|
-
* in the provided array of supported levels.
|
|
641
|
-
*/
|
|
642
|
-
export const clampThinkingLevel = (
|
|
643
|
-
requested: ThinkingLevel,
|
|
644
|
-
supported: ThinkingLevel[] | undefined,
|
|
645
|
-
): ThinkingLevel => {
|
|
646
|
-
if (requested === 'off' || !supported || supported.length === 0) {
|
|
647
|
-
return 'off';
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const reqIdx = THINKING_LEVELS.indexOf(requested);
|
|
651
|
-
for (let i = reqIdx; i >= 0; i--) {
|
|
652
|
-
if (supported.includes(THINKING_LEVELS[i])) {
|
|
653
|
-
return THINKING_LEVELS[i];
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return 'off';
|
|
658
|
-
};
|