@alexeiled/pi-model-router 0.5.2 → 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 +11 -0
- package/README.md +141 -22
- package/extensions/classifier.ts +96 -70
- package/extensions/commands.ts +39 -23
- package/extensions/config.ts +193 -102
- package/extensions/context.ts +61 -28
- package/extensions/index.ts +29 -10
- package/extensions/jev.ts +223 -0
- package/extensions/provider.ts +346 -145
- package/extensions/routing.ts +236 -296
- package/extensions/state.ts +53 -10
- package/extensions/types.ts +80 -12
- package/extensions/ui.ts +19 -7
- package/model-router.example.json +17 -10
- package/package.json +1 -1
package/extensions/config.ts
CHANGED
|
@@ -7,6 +7,7 @@ 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,
|
|
12
13
|
RawRouterConfig,
|
|
@@ -14,10 +15,11 @@ import type {
|
|
|
14
15
|
RouterConfig,
|
|
15
16
|
RouterProfile,
|
|
16
17
|
RouterTier,
|
|
17
|
-
RoutingRule,
|
|
18
18
|
} from './types';
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
import { ROUTER_TIERS } from './types';
|
|
21
|
+
|
|
22
|
+
export { ROUTER_TIERS } from './types';
|
|
21
23
|
|
|
22
24
|
// Pi accepts this model capability at runtime, but older peer type releases omit it.
|
|
23
25
|
export const MAX_THINKING_LEVEL: ThinkingLevel = 'max';
|
|
@@ -31,7 +33,7 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
|
31
33
|
'xhigh',
|
|
32
34
|
MAX_THINKING_LEVEL,
|
|
33
35
|
];
|
|
34
|
-
export const ROUTER_PIN_VALUES = ['auto',
|
|
36
|
+
export const ROUTER_PIN_VALUES = ['auto', ...ROUTER_TIERS] as const;
|
|
35
37
|
export type RouterPinValue = (typeof ROUTER_PIN_VALUES)[number];
|
|
36
38
|
export const isRouterPinValue = (value: unknown): value is RouterPinValue =>
|
|
37
39
|
ROUTER_PIN_VALUES.some((candidate) => candidate === value);
|
|
@@ -51,7 +53,7 @@ export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
|
|
|
51
53
|
typeof value === 'string' && THINKING_LEVELS.some((level) => level === value);
|
|
52
54
|
|
|
53
55
|
export const isRouterTier = (value: unknown): value is RouterTier =>
|
|
54
|
-
|
|
56
|
+
ROUTER_TIERS.some((tier) => tier === value);
|
|
55
57
|
|
|
56
58
|
export const parseConfigFile = (path: string): ParsedConfigFile => {
|
|
57
59
|
if (!existsSync(path)) {
|
|
@@ -67,12 +69,11 @@ export const parseConfigFile = (path: string): ParsedConfigFile => {
|
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
return { config: parsed, warnings: [] };
|
|
70
|
-
} catch
|
|
72
|
+
} catch {
|
|
73
|
+
// JSON parse errors can include source snippets containing credentials.
|
|
71
74
|
return {
|
|
72
75
|
config: {},
|
|
73
|
-
warnings: [
|
|
74
|
-
`Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
75
|
-
],
|
|
76
|
+
warnings: [`Failed to parse router config at ${path}.`],
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
};
|
|
@@ -121,9 +122,12 @@ export const mergeConfig = (
|
|
|
121
122
|
? mergedProfiles[name]
|
|
122
123
|
: {};
|
|
123
124
|
mergedProfiles[name] = {
|
|
125
|
+
baselineTier: mergeRawValue(existing.baselineTier, profile.baselineTier),
|
|
124
126
|
high: mergeRawValue(existing.high, profile.high),
|
|
125
127
|
medium: mergeRawValue(existing.medium, profile.medium),
|
|
126
128
|
low: mergeRawValue(existing.low, profile.low),
|
|
129
|
+
micro: mergeRawValue(existing.micro, profile.micro),
|
|
130
|
+
jev: mergeRawValue(existing.jev, profile.jev),
|
|
127
131
|
};
|
|
128
132
|
}
|
|
129
133
|
|
|
@@ -132,6 +136,7 @@ export const mergeConfig = (
|
|
|
132
136
|
const mergedModels = { ...baseModels, ...overrideModels };
|
|
133
137
|
|
|
134
138
|
return {
|
|
139
|
+
jev: mergeRawValue(base.jev, override.jev),
|
|
135
140
|
debug: override.debug ?? base.debug,
|
|
136
141
|
classifierModel: override.classifierModel ?? base.classifierModel,
|
|
137
142
|
phaseBias: override.phaseBias ?? base.phaseBias,
|
|
@@ -147,16 +152,12 @@ export const parseCanonicalModelRef = (
|
|
|
147
152
|
): { provider: string; modelId: string } => {
|
|
148
153
|
const slashIndex = value.indexOf('/');
|
|
149
154
|
if (slashIndex === -1) {
|
|
150
|
-
throw new Error(
|
|
151
|
-
`Invalid model reference "${value}". Expected "provider/model".`,
|
|
152
|
-
);
|
|
155
|
+
throw new Error('Invalid model reference. Expected "provider/model".');
|
|
153
156
|
}
|
|
154
157
|
const provider = value.slice(0, slashIndex).trim();
|
|
155
158
|
const modelId = value.slice(slashIndex + 1).trim();
|
|
156
159
|
if (!provider || !modelId) {
|
|
157
|
-
throw new Error(
|
|
158
|
-
`Invalid model reference "${value}". Expected "provider/model".`,
|
|
159
|
-
);
|
|
160
|
+
throw new Error('Invalid model reference. Expected "provider/model".');
|
|
160
161
|
}
|
|
161
162
|
return { provider, modelId };
|
|
162
163
|
};
|
|
@@ -180,7 +181,7 @@ export const normalizeModelsMap = (
|
|
|
180
181
|
continue;
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
|
|
184
|
+
let model = typeof entry.model === 'string' ? entry.model.trim() : '';
|
|
184
185
|
if (!model) {
|
|
185
186
|
warnings.push(
|
|
186
187
|
`Model definition "${alias}" is missing the "model" field. Skipped.`,
|
|
@@ -189,10 +190,11 @@ export const normalizeModelsMap = (
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
try {
|
|
192
|
-
parseCanonicalModelRef(model);
|
|
193
|
-
|
|
193
|
+
const { provider, modelId } = parseCanonicalModelRef(model);
|
|
194
|
+
model = `${provider}/${modelId}`;
|
|
195
|
+
} catch {
|
|
194
196
|
warnings.push(
|
|
195
|
-
`Model definition "${alias}"
|
|
197
|
+
`Model definition "${alias}" has an invalid model reference. Skipped.`,
|
|
196
198
|
);
|
|
197
199
|
continue;
|
|
198
200
|
}
|
|
@@ -265,23 +267,31 @@ export const normalizeTierConfig = (
|
|
|
265
267
|
const aliasDefinition = resolved.definition;
|
|
266
268
|
let parsedModel: string;
|
|
267
269
|
try {
|
|
268
|
-
parseCanonicalModelRef(resolved.canonicalRef);
|
|
269
|
-
parsedModel =
|
|
270
|
-
} catch
|
|
270
|
+
const { provider, modelId } = parseCanonicalModelRef(resolved.canonicalRef);
|
|
271
|
+
parsedModel = `${provider}/${modelId}`;
|
|
272
|
+
} catch {
|
|
271
273
|
warnings.push(
|
|
272
|
-
`Profile "${profileName}" ${tier} tier
|
|
274
|
+
`Profile "${profileName}" ${tier} tier has an invalid model reference. Tier disabled.`,
|
|
273
275
|
);
|
|
274
276
|
return undefined;
|
|
275
277
|
}
|
|
276
278
|
|
|
277
|
-
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;
|
|
278
287
|
if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
|
|
279
288
|
warnings.push(
|
|
280
|
-
`Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to
|
|
289
|
+
`Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to ${defaultThinking}.`,
|
|
281
290
|
);
|
|
282
291
|
}
|
|
283
292
|
|
|
284
293
|
let fallbacks: string[] | undefined;
|
|
294
|
+
const resolvedFallbacks: ModelDefinition[] = [];
|
|
285
295
|
if (Array.isArray(value.fallbacks)) {
|
|
286
296
|
fallbacks = [];
|
|
287
297
|
for (const f of value.fallbacks) {
|
|
@@ -289,11 +299,15 @@ export const normalizeTierConfig = (
|
|
|
289
299
|
// Resolve aliases in fallbacks too
|
|
290
300
|
const resolvedFallback = resolveModelRef(f, models);
|
|
291
301
|
try {
|
|
292
|
-
parseCanonicalModelRef(
|
|
293
|
-
|
|
294
|
-
|
|
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 {
|
|
295
309
|
warnings.push(
|
|
296
|
-
`Invalid fallback model
|
|
310
|
+
`Invalid fallback model in profile "${profileName}" ${tier} tier. Ignored.`,
|
|
297
311
|
);
|
|
298
312
|
}
|
|
299
313
|
}
|
|
@@ -318,11 +332,6 @@ export const normalizeTierConfig = (
|
|
|
318
332
|
const resolvedMaxTokens =
|
|
319
333
|
tierMaxTokens ?? aliasDefinition?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
320
334
|
|
|
321
|
-
// Resolve reasoning: tier config > alias > undefined (assumed true)
|
|
322
|
-
const tierReasoning =
|
|
323
|
-
typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
|
|
324
|
-
const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
|
|
325
|
-
|
|
326
335
|
// Resolve thinkingLevels: tier config > alias > default
|
|
327
336
|
// Validate tier-level thinkingLevels array
|
|
328
337
|
let tierThinkingLevels: ThinkingLevel[] | undefined;
|
|
@@ -353,11 +362,13 @@ export const normalizeTierConfig = (
|
|
|
353
362
|
|
|
354
363
|
return {
|
|
355
364
|
model: parsedModel,
|
|
365
|
+
thinkingExplicit: isThinkingLevel(value.thinking),
|
|
356
366
|
thinking,
|
|
357
367
|
fallbacks,
|
|
368
|
+
resolvedFallbacks,
|
|
358
369
|
contextWindow: tierContextWindow,
|
|
359
370
|
maxTokens: tierMaxTokens,
|
|
360
|
-
reasoning:
|
|
371
|
+
reasoning: effectiveReasoning,
|
|
361
372
|
thinkingLevels: tierThinkingLevels,
|
|
362
373
|
resolvedContextWindow,
|
|
363
374
|
resolvedMaxTokens,
|
|
@@ -365,6 +376,108 @@ export const normalizeTierConfig = (
|
|
|
365
376
|
};
|
|
366
377
|
};
|
|
367
378
|
|
|
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
|
+
|
|
368
481
|
export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
369
482
|
const warnings: string[] = [];
|
|
370
483
|
|
|
@@ -401,53 +514,57 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
|
401
514
|
hasModels ? normalizedModels : undefined,
|
|
402
515
|
);
|
|
403
516
|
|
|
404
|
-
|
|
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) {
|
|
405
526
|
warnings.push(`Profile "${name}" has no valid tiers. Skipped.`);
|
|
406
527
|
continue;
|
|
407
528
|
}
|
|
408
529
|
|
|
409
|
-
|
|
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
|
+
};
|
|
410
556
|
}
|
|
411
557
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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.');
|
|
416
562
|
|
|
417
563
|
const maxSessionBudget =
|
|
418
564
|
typeof raw.maxSessionBudget === 'number' && raw.maxSessionBudget > 0
|
|
419
565
|
? raw.maxSessionBudget
|
|
420
566
|
: undefined;
|
|
421
567
|
|
|
422
|
-
const rules: RoutingRule[] = [];
|
|
423
|
-
if (Array.isArray(raw.rules)) {
|
|
424
|
-
for (const rule of raw.rules) {
|
|
425
|
-
if (isObjectRecord(rule)) {
|
|
426
|
-
const matches = rule.matches;
|
|
427
|
-
const tier = rule.tier;
|
|
428
|
-
if (
|
|
429
|
-
((typeof matches === 'string' && matches.trim().length > 0) ||
|
|
430
|
-
(Array.isArray(matches) &&
|
|
431
|
-
matches.length > 0 &&
|
|
432
|
-
matches.every(
|
|
433
|
-
(m) => typeof m === 'string' && m.trim().length > 0,
|
|
434
|
-
))) &&
|
|
435
|
-
isRouterTier(tier)
|
|
436
|
-
) {
|
|
437
|
-
rules.push({
|
|
438
|
-
matches,
|
|
439
|
-
tier,
|
|
440
|
-
reason: typeof rule.reason === 'string' ? rule.reason : undefined,
|
|
441
|
-
});
|
|
442
|
-
} else {
|
|
443
|
-
warnings.push(
|
|
444
|
-
`Ignored invalid routing rule: ${JSON.stringify(rule)}`,
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
568
|
// Resolve classifierModel — accepts string or { model, thinking } object
|
|
452
569
|
let classifierModel: ClassifierConfig | undefined;
|
|
453
570
|
const rawClassifier = raw.classifierModel;
|
|
@@ -459,10 +576,8 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
|
459
576
|
try {
|
|
460
577
|
parseCanonicalModelRef(resolved.canonicalRef);
|
|
461
578
|
classifierModel = { model: resolved.canonicalRef };
|
|
462
|
-
} catch
|
|
463
|
-
warnings.push(
|
|
464
|
-
`Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
|
|
465
|
-
);
|
|
579
|
+
} catch {
|
|
580
|
+
warnings.push('Invalid classifierModel model reference. Ignored.');
|
|
466
581
|
}
|
|
467
582
|
} else if (isObjectRecord(rawClassifier)) {
|
|
468
583
|
const modelRef =
|
|
@@ -479,14 +594,12 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
|
479
594
|
: undefined;
|
|
480
595
|
if (rawClassifier.thinking !== undefined && !thinking) {
|
|
481
596
|
warnings.push(
|
|
482
|
-
|
|
597
|
+
'classifierModel has an invalid thinking level. Ignored.',
|
|
483
598
|
);
|
|
484
599
|
}
|
|
485
600
|
classifierModel = { model: resolved.canonicalRef, thinking };
|
|
486
|
-
} catch
|
|
487
|
-
warnings.push(
|
|
488
|
-
`Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
|
|
489
|
-
);
|
|
601
|
+
} catch {
|
|
602
|
+
warnings.push('Invalid classifierModel model reference. Ignored.');
|
|
490
603
|
}
|
|
491
604
|
} else {
|
|
492
605
|
warnings.push(
|
|
@@ -497,11 +610,10 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
|
497
610
|
|
|
498
611
|
return {
|
|
499
612
|
config: {
|
|
613
|
+
jev: normalizeJevConfig(raw.jev, warnings),
|
|
500
614
|
debug: typeof raw.debug === 'boolean' ? raw.debug : false,
|
|
501
615
|
classifierModel,
|
|
502
|
-
phaseBias,
|
|
503
616
|
maxSessionBudget,
|
|
504
|
-
rules: rules.length > 0 ? rules : undefined,
|
|
505
617
|
profiles: normalizedProfiles,
|
|
506
618
|
models: hasModels ? normalizedModels : undefined,
|
|
507
619
|
},
|
|
@@ -517,7 +629,7 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
|
|
|
517
629
|
const baseConfig: RawRouterConfig = { profiles: {} };
|
|
518
630
|
const merged = mergeConfig(
|
|
519
631
|
mergeConfig(baseConfig, globalResult.config),
|
|
520
|
-
projectResult.config,
|
|
632
|
+
stripProjectJevConfig(projectResult.config, projectResult.warnings),
|
|
521
633
|
);
|
|
522
634
|
const normalized = normalizeConfig(merged);
|
|
523
635
|
return {
|
|
@@ -638,24 +750,3 @@ export const getUnsupportedTiers = (
|
|
|
638
750
|
}
|
|
639
751
|
return unsupported;
|
|
640
752
|
};
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Clamps a requested thinking level to the highest supported level
|
|
644
|
-
* in the provided array of supported levels.
|
|
645
|
-
*/
|
|
646
|
-
export const clampThinkingLevel = (
|
|
647
|
-
requested: ThinkingLevel,
|
|
648
|
-
supported: ThinkingLevel[] | undefined,
|
|
649
|
-
): ThinkingLevel => {
|
|
650
|
-
if (requested === 'off' || !supported || supported.length === 0) {
|
|
651
|
-
return 'off';
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const reqIdx = THINKING_LEVELS.indexOf(requested);
|
|
655
|
-
for (let i = reqIdx; i >= 0; i--) {
|
|
656
|
-
const level = THINKING_LEVELS[i];
|
|
657
|
-
if (level && supported.includes(level)) return level;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
return 'off';
|
|
661
|
-
};
|
package/extensions/context.ts
CHANGED
|
@@ -17,35 +17,71 @@ export const extractTextFromContent = (
|
|
|
17
17
|
.join('\n');
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/** Text blocks only: never include system/config, thinking, tool arguments or binary data. */
|
|
21
|
+
export const getBoundedRecentContext = (
|
|
22
|
+
context: Context,
|
|
23
|
+
maxChars: number,
|
|
24
|
+
): string => {
|
|
25
|
+
if (!Number.isFinite(maxChars) || maxChars < 1) return '';
|
|
26
|
+
const budget = Math.floor(maxChars);
|
|
27
|
+
const latestUser = context.messages.findLastIndex(
|
|
28
|
+
(message) => message.role === 'user',
|
|
29
|
+
);
|
|
30
|
+
const selected = new Map<number, string>();
|
|
31
|
+
const render = (message: Message, limit: number): string => {
|
|
32
|
+
const label = `${message.role === 'toolResult' ? 'tool' : message.role}:\n`;
|
|
33
|
+
// For tiny budgets prioritize request text over a partial role label.
|
|
34
|
+
const prefix = limit > label.length ? label : '';
|
|
35
|
+
let text = '';
|
|
36
|
+
const remaining = limit - prefix.length;
|
|
37
|
+
if (typeof message.content === 'string') {
|
|
38
|
+
text = message.content.slice(0, remaining);
|
|
39
|
+
} else {
|
|
40
|
+
for (const part of message.content) {
|
|
41
|
+
if (part.type !== 'text' || !part.text) continue;
|
|
42
|
+
text +=
|
|
43
|
+
`${text ? '\n' : ''}${part.text.slice(0, remaining - text.length)}`.slice(
|
|
44
|
+
0,
|
|
45
|
+
remaining - text.length,
|
|
46
|
+
);
|
|
47
|
+
if (text.length >= remaining) break;
|
|
48
|
+
}
|
|
25
49
|
}
|
|
50
|
+
return text ? prefix + text : '';
|
|
51
|
+
};
|
|
52
|
+
const request = context.messages[latestUser];
|
|
53
|
+
const latest = request ? render(request, budget) : '';
|
|
54
|
+
if (latest) selected.set(latestUser, latest);
|
|
55
|
+
let remaining = budget - latest.length;
|
|
56
|
+
const recent = context.messages
|
|
57
|
+
.map((message, index) => ({ message, index }))
|
|
58
|
+
.filter(
|
|
59
|
+
({ message, index }) =>
|
|
60
|
+
index !== latestUser &&
|
|
61
|
+
(message.role === 'user' ||
|
|
62
|
+
message.role === 'assistant' ||
|
|
63
|
+
message.role === 'toolResult'),
|
|
64
|
+
)
|
|
65
|
+
.slice(-5)
|
|
66
|
+
.reverse();
|
|
67
|
+
for (const [position, { message, index }] of recent.entries()) {
|
|
68
|
+
const separator = selected.size ? 2 : 0;
|
|
69
|
+
// Share the remainder so one oversized tool result cannot erase all history.
|
|
70
|
+
const allowance = Math.floor(
|
|
71
|
+
(remaining - separator) / (recent.length - position),
|
|
72
|
+
);
|
|
73
|
+
if (allowance <= 0) break;
|
|
74
|
+
const text = render(message, allowance);
|
|
75
|
+
if (!text) continue;
|
|
76
|
+
selected.set(index, text);
|
|
77
|
+
remaining -= text.length + separator;
|
|
26
78
|
}
|
|
27
|
-
return
|
|
79
|
+
return [...selected.entries()]
|
|
80
|
+
.sort(([left], [right]) => left - right)
|
|
81
|
+
.map(([, text]) => text)
|
|
82
|
+
.join('\n\n');
|
|
28
83
|
};
|
|
29
84
|
|
|
30
|
-
export const getRecentConversationText = (
|
|
31
|
-
context: Context,
|
|
32
|
-
limit = 6,
|
|
33
|
-
): string =>
|
|
34
|
-
context.messages
|
|
35
|
-
.slice(-limit)
|
|
36
|
-
.map((message) =>
|
|
37
|
-
message ? extractTextFromContent(message.content).trim() : '',
|
|
38
|
-
)
|
|
39
|
-
.filter(Boolean)
|
|
40
|
-
.join('\n')
|
|
41
|
-
.toLowerCase();
|
|
42
|
-
|
|
43
|
-
export const countToolResults = (context: Context): number =>
|
|
44
|
-
context.messages.filter((message) => message?.role === 'toolResult').length;
|
|
45
|
-
|
|
46
|
-
export const countWords = (text: string): number =>
|
|
47
|
-
text.split(/\s+/).filter(Boolean).length;
|
|
48
|
-
|
|
49
85
|
export const hasImageAttachment = (context: Context): boolean =>
|
|
50
86
|
context.messages.some(
|
|
51
87
|
(message) =>
|
|
@@ -53,6 +89,3 @@ export const hasImageAttachment = (context: Context): boolean =>
|
|
|
53
89
|
Array.isArray(message.content) &&
|
|
54
90
|
message.content.some((part) => part.type === 'image'),
|
|
55
91
|
);
|
|
56
|
-
|
|
57
|
-
export const containsAny = (text: string, keywords: string[]): boolean =>
|
|
58
|
-
keywords.some((keyword) => text.includes(keyword));
|
package/extensions/index.ts
CHANGED
|
@@ -14,11 +14,13 @@ import {
|
|
|
14
14
|
} from './config';
|
|
15
15
|
import { MAX_DEBUG_HISTORY } from './constants';
|
|
16
16
|
import { registerRouterProvider } from './provider';
|
|
17
|
+
import { preservesRouteCoverage } from './routing';
|
|
17
18
|
import {
|
|
18
19
|
buildPersistedState,
|
|
19
20
|
isRouterPersistedState,
|
|
20
21
|
loadLastRouterProfile,
|
|
21
22
|
saveLastRouterProfile,
|
|
23
|
+
snapshotDecision,
|
|
22
24
|
} from './state';
|
|
23
25
|
import type {
|
|
24
26
|
RouterConfig,
|
|
@@ -151,7 +153,9 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
151
153
|
};
|
|
152
154
|
|
|
153
155
|
const recordDebugDecision = (decision: RoutingDecision) => {
|
|
154
|
-
debugHistory = [...debugHistory, decision].slice(
|
|
156
|
+
debugHistory = [...debugHistory, snapshotDecision(decision)].slice(
|
|
157
|
+
-MAX_DEBUG_HISTORY,
|
|
158
|
+
);
|
|
155
159
|
};
|
|
156
160
|
|
|
157
161
|
const getThinkingOverride = (profileName: string, tier: RouterTier) => {
|
|
@@ -208,7 +212,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
208
212
|
lastNonRouterModel,
|
|
209
213
|
accumulatedCost,
|
|
210
214
|
widgetEnabled,
|
|
211
|
-
currentConfig,
|
|
215
|
+
maxSessionBudget: currentConfig.maxSessionBudget,
|
|
212
216
|
}),
|
|
213
217
|
reloadConfig: (
|
|
214
218
|
ctx?: ExtensionContext,
|
|
@@ -369,13 +373,15 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
369
373
|
debugEnabled = savedState.debugEnabled ?? debugEnabled;
|
|
370
374
|
widgetEnabled = savedState.widgetEnabled ?? widgetEnabled;
|
|
371
375
|
debugHistory = savedState.debugHistory
|
|
372
|
-
?
|
|
376
|
+
? savedState.debugHistory
|
|
377
|
+
.map(snapshotDecision)
|
|
378
|
+
.slice(-MAX_DEBUG_HISTORY)
|
|
373
379
|
: [];
|
|
374
380
|
if (!hasExplicitStartupModel) {
|
|
375
381
|
lastNonRouterModel =
|
|
376
382
|
savedState.lastNonRouterModel ?? lastNonRouterModel;
|
|
377
383
|
lastDecision = savedState.lastDecision
|
|
378
|
-
?
|
|
384
|
+
? snapshotDecision(savedState.lastDecision)
|
|
379
385
|
: undefined;
|
|
380
386
|
}
|
|
381
387
|
accumulatedCost = savedState.accumulatedCost ?? 0;
|
|
@@ -516,14 +522,27 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
516
522
|
|
|
517
523
|
// User changed pi's thinking level (e.g. via shift+tab).
|
|
518
524
|
// Apply as an all-tier thinking override for the active router profile.
|
|
519
|
-
|
|
520
|
-
if (!overrides) {
|
|
521
|
-
overrides = {};
|
|
522
|
-
thinkingByProfile[selectedProfile] = overrides;
|
|
523
|
-
}
|
|
525
|
+
const overrides = { ...thinkingByProfile[selectedProfile] };
|
|
524
526
|
for (const t of ROUTER_TIERS) {
|
|
525
527
|
overrides[t] = event.level;
|
|
526
528
|
}
|
|
529
|
+
const activeProfile = currentConfig.profiles[selectedProfile];
|
|
530
|
+
if (!activeProfile) return;
|
|
531
|
+
if (
|
|
532
|
+
preservesRouteCoverage(
|
|
533
|
+
activeProfile,
|
|
534
|
+
(provider, id) => ctx.modelRegistry.find(provider, id),
|
|
535
|
+
overrides,
|
|
536
|
+
) === false
|
|
537
|
+
) {
|
|
538
|
+
actions.syncPiThinkingLevel(event.previousLevel);
|
|
539
|
+
ctx.ui.notify(
|
|
540
|
+
`Router thinking unchanged: '${event.level}' leaves no eligible route.`,
|
|
541
|
+
'warning',
|
|
542
|
+
);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
thinkingByProfile[selectedProfile] = overrides;
|
|
527
546
|
persistState();
|
|
528
547
|
actions.updateStatus(ctx);
|
|
529
548
|
if (event.level !== 'off') {
|
|
@@ -533,7 +552,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
533
552
|
if (unsupported.length > 0) {
|
|
534
553
|
ctx.ui.notify(
|
|
535
554
|
`Router thinking (all) set to ${event.level}. ` +
|
|
536
|
-
`${unsupported.join(', ')} tier${unsupported.length > 1 ? 's' : ''} may not support '${event.level}'.`,
|
|
555
|
+
`${unsupported.join(', ')} tier${unsupported.length > 1 ? 's' : ''} may not support '${event.level}' and will be skipped when unsupported.`,
|
|
537
556
|
'warning',
|
|
538
557
|
);
|
|
539
558
|
}
|