@alexeiled/pi-model-router 0.6.4 → 0.7.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 +39 -0
- package/README.md +56 -183
- package/extensions/classifier.ts +4 -5
- package/extensions/commands.ts +280 -572
- package/extensions/config.ts +100 -9
- package/extensions/constants.ts +17 -0
- package/extensions/context.ts +185 -0
- package/extensions/jev.ts +355 -80
- package/extensions/provider.ts +33 -14
- package/extensions/state.ts +55 -3
- package/extensions/types.ts +82 -4
- package/extensions/ui.ts +62 -9
- package/model-router.example.json +9 -1
- package/package.json +1 -1
package/extensions/config.ts
CHANGED
|
@@ -3,11 +3,22 @@ import { join } from 'node:path';
|
|
|
3
3
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
4
4
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
5
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
8
|
+
DEFAULT_JEV_CONTEXT,
|
|
9
|
+
DEFAULT_JEV_RETRY,
|
|
10
|
+
DEFAULT_MAX_TOKENS,
|
|
11
|
+
MAX_JEV_ATTEMPTS,
|
|
12
|
+
MAX_JEV_BACKOFF_MS,
|
|
13
|
+
MAX_JEV_CONTEXT_TURNS,
|
|
14
|
+
MAX_JEV_STATE_TOKENS,
|
|
15
|
+
} from './constants';
|
|
7
16
|
import type {
|
|
8
17
|
ClassifierConfig,
|
|
9
18
|
ConfigLoadResult,
|
|
10
19
|
JevConfig,
|
|
20
|
+
JevContextConfig,
|
|
21
|
+
JevRetryConfig,
|
|
11
22
|
ModelDefinition,
|
|
12
23
|
ParsedConfigFile,
|
|
13
24
|
RawRouterConfig,
|
|
@@ -135,9 +146,18 @@ export const mergeConfig = (
|
|
|
135
146
|
const overrideModels = isObjectRecord(override.models) ? override.models : {};
|
|
136
147
|
const mergedModels = { ...baseModels, ...overrideModels };
|
|
137
148
|
|
|
149
|
+
const mergedJev = mergeRawValue(base.jev, override.jev);
|
|
150
|
+
const nestedJev = (key: 'context' | 'retry') =>
|
|
151
|
+
mergeRawValue(
|
|
152
|
+
isObjectRecord(base.jev) ? base.jev[key] : undefined,
|
|
153
|
+
isObjectRecord(override.jev) ? override.jev[key] : undefined,
|
|
154
|
+
);
|
|
155
|
+
const jev = isObjectRecord(mergedJev)
|
|
156
|
+
? { ...mergedJev, context: nestedJev('context'), retry: nestedJev('retry') }
|
|
157
|
+
: mergedJev;
|
|
138
158
|
return {
|
|
139
159
|
ui: mergeRawValue(base.ui, override.ui),
|
|
140
|
-
jev
|
|
160
|
+
jev,
|
|
141
161
|
debug: override.debug ?? base.debug,
|
|
142
162
|
classifierModel: override.classifierModel ?? base.classifierModel,
|
|
143
163
|
phaseBias: override.phaseBias ?? base.phaseBias,
|
|
@@ -385,7 +405,8 @@ export const DEFAULT_JEV_CONFIG = {
|
|
|
385
405
|
model: 'jev-1.13.0',
|
|
386
406
|
timeoutMs: 1500,
|
|
387
407
|
confidenceThreshold: 0.65,
|
|
388
|
-
|
|
408
|
+
probabilityThreshold: 0.8,
|
|
409
|
+
maxStateTokens: 3000,
|
|
389
410
|
mode: 'advisory',
|
|
390
411
|
} as const;
|
|
391
412
|
|
|
@@ -405,6 +426,55 @@ export const isJevEndpoint = (value: unknown): value is string => {
|
|
|
405
426
|
}
|
|
406
427
|
};
|
|
407
428
|
|
|
429
|
+
const normalizeJevContext = (raw: unknown): JevContextConfig | undefined => {
|
|
430
|
+
if (raw === undefined) return { ...DEFAULT_JEV_CONTEXT };
|
|
431
|
+
if (
|
|
432
|
+
!isObjectRecord(raw) ||
|
|
433
|
+
Object.keys(raw).some((key) => !Object.hasOwn(DEFAULT_JEV_CONTEXT, key))
|
|
434
|
+
)
|
|
435
|
+
return undefined;
|
|
436
|
+
const context = { ...DEFAULT_JEV_CONTEXT, ...raw };
|
|
437
|
+
if (
|
|
438
|
+
typeof context.previousTurns !== 'number' ||
|
|
439
|
+
!Number.isSafeInteger(context.previousTurns) ||
|
|
440
|
+
context.previousTurns < 0 ||
|
|
441
|
+
context.previousTurns > MAX_JEV_CONTEXT_TURNS ||
|
|
442
|
+
typeof context.maxHistoryTokens !== 'number' ||
|
|
443
|
+
!Number.isInteger(context.maxHistoryTokens) ||
|
|
444
|
+
context.maxHistoryTokens < 0 ||
|
|
445
|
+
context.maxHistoryTokens > MAX_JEV_STATE_TOKENS ||
|
|
446
|
+
typeof context.maxToolTokens !== 'number' ||
|
|
447
|
+
!Number.isInteger(context.maxToolTokens) ||
|
|
448
|
+
context.maxToolTokens < 0 ||
|
|
449
|
+
context.maxToolTokens > MAX_JEV_STATE_TOKENS ||
|
|
450
|
+
!['none', 'last', 'last-error'].includes(context.toolResults)
|
|
451
|
+
)
|
|
452
|
+
return undefined;
|
|
453
|
+
return context;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const normalizeJevRetry = (raw: unknown): JevRetryConfig | undefined => {
|
|
457
|
+
if (raw === undefined) return { ...DEFAULT_JEV_RETRY };
|
|
458
|
+
if (
|
|
459
|
+
!isObjectRecord(raw) ||
|
|
460
|
+
Object.keys(raw).some((key) => !Object.hasOwn(DEFAULT_JEV_RETRY, key))
|
|
461
|
+
)
|
|
462
|
+
return undefined;
|
|
463
|
+
const retry = { ...DEFAULT_JEV_RETRY, ...raw };
|
|
464
|
+
if (
|
|
465
|
+
typeof retry.maxAttempts !== 'number' ||
|
|
466
|
+
!Number.isSafeInteger(retry.maxAttempts) ||
|
|
467
|
+
retry.maxAttempts < 1 ||
|
|
468
|
+
retry.maxAttempts > MAX_JEV_ATTEMPTS ||
|
|
469
|
+
typeof retry.backoffMs !== 'number' ||
|
|
470
|
+
!Number.isSafeInteger(retry.backoffMs) ||
|
|
471
|
+
retry.backoffMs < 0 ||
|
|
472
|
+
retry.backoffMs > MAX_JEV_BACKOFF_MS
|
|
473
|
+
)
|
|
474
|
+
return undefined;
|
|
475
|
+
return retry;
|
|
476
|
+
};
|
|
477
|
+
|
|
408
478
|
export const normalizeJevConfig = (
|
|
409
479
|
raw: unknown,
|
|
410
480
|
warnings: string[],
|
|
@@ -416,6 +486,10 @@ export const normalizeJevConfig = (
|
|
|
416
486
|
};
|
|
417
487
|
if (!isObjectRecord(raw)) return invalid();
|
|
418
488
|
const value: Record<string, unknown> = { ...DEFAULT_JEV_CONFIG, ...raw };
|
|
489
|
+
const context = normalizeJevContext(value.context);
|
|
490
|
+
if (!context) return invalid();
|
|
491
|
+
const retry = normalizeJevRetry(value.retry);
|
|
492
|
+
if (!retry) return invalid();
|
|
419
493
|
if (
|
|
420
494
|
(value.enabled !== undefined && typeof value.enabled !== 'boolean') ||
|
|
421
495
|
!isJevEndpoint(value.endpoint) ||
|
|
@@ -429,10 +503,14 @@ export const normalizeJevConfig = (
|
|
|
429
503
|
!Number.isFinite(value.confidenceThreshold) ||
|
|
430
504
|
value.confidenceThreshold < 0 ||
|
|
431
505
|
value.confidenceThreshold > 1 ||
|
|
432
|
-
typeof value.
|
|
433
|
-
!Number.
|
|
434
|
-
value.
|
|
435
|
-
value.
|
|
506
|
+
typeof value.probabilityThreshold !== 'number' ||
|
|
507
|
+
!Number.isFinite(value.probabilityThreshold) ||
|
|
508
|
+
value.probabilityThreshold <= 0 ||
|
|
509
|
+
value.probabilityThreshold > 1 ||
|
|
510
|
+
typeof value.maxStateTokens !== 'number' ||
|
|
511
|
+
!Number.isInteger(value.maxStateTokens) ||
|
|
512
|
+
value.maxStateTokens < 1 ||
|
|
513
|
+
value.maxStateTokens > MAX_JEV_STATE_TOKENS ||
|
|
436
514
|
value.mode !== 'advisory' ||
|
|
437
515
|
(value.apiKey !== undefined &&
|
|
438
516
|
(typeof value.apiKey !== 'string' || /[\r\n]/.test(value.apiKey)))
|
|
@@ -449,7 +527,10 @@ export const normalizeJevConfig = (
|
|
|
449
527
|
model: value.model,
|
|
450
528
|
timeoutMs: value.timeoutMs,
|
|
451
529
|
confidenceThreshold: value.confidenceThreshold,
|
|
452
|
-
|
|
530
|
+
probabilityThreshold: value.probabilityThreshold,
|
|
531
|
+
maxStateTokens: value.maxStateTokens,
|
|
532
|
+
context,
|
|
533
|
+
retry,
|
|
453
534
|
mode: 'advisory',
|
|
454
535
|
};
|
|
455
536
|
};
|
|
@@ -597,7 +678,17 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
|
597
678
|
'classifierModel has an invalid thinking level. Ignored.',
|
|
598
679
|
);
|
|
599
680
|
}
|
|
600
|
-
|
|
681
|
+
const timeoutMs =
|
|
682
|
+
typeof rawClassifier.timeoutMs === 'number' &&
|
|
683
|
+
Number.isFinite(rawClassifier.timeoutMs) &&
|
|
684
|
+
rawClassifier.timeoutMs > 0 &&
|
|
685
|
+
rawClassifier.timeoutMs <= MAX_TIMER_DELAY_MS
|
|
686
|
+
? rawClassifier.timeoutMs
|
|
687
|
+
: undefined;
|
|
688
|
+
if (rawClassifier.timeoutMs !== undefined && timeoutMs === undefined) {
|
|
689
|
+
warnings.push('classifierModel has an invalid timeoutMs. Ignored.');
|
|
690
|
+
}
|
|
691
|
+
classifierModel = { model: resolved.canonicalRef, thinking, timeoutMs };
|
|
601
692
|
} catch {
|
|
602
693
|
warnings.push('Invalid classifierModel model reference. Ignored.');
|
|
603
694
|
}
|
package/extensions/constants.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
export const MAX_DEBUG_HISTORY = 50;
|
|
2
|
+
// Bound JSON metadata as well as text; expand only with measured long-dialogue needs.
|
|
3
|
+
export const MAX_JEV_CONTEXT_TURNS = 20;
|
|
4
|
+
export const MAX_JEV_STATE_TOKENS = 24_000;
|
|
5
|
+
export const MAX_JEV_ESTIMATED_REQUEST_TOKENS = 28_000;
|
|
6
|
+
export const DEFAULT_JEV_CONTEXT = {
|
|
7
|
+
previousTurns: 2,
|
|
8
|
+
maxHistoryTokens: 500,
|
|
9
|
+
toolResults: 'last-error',
|
|
10
|
+
maxToolTokens: 250,
|
|
11
|
+
} as const;
|
|
12
|
+
/** One retry of a documented transient status; the total budget stays `jev.timeoutMs`. */
|
|
13
|
+
export const DEFAULT_JEV_RETRY = { maxAttempts: 2, backoffMs: 400 } as const;
|
|
14
|
+
export const MAX_JEV_ATTEMPTS = 5;
|
|
15
|
+
export const MAX_JEV_BACKOFF_MS = 60_000;
|
|
16
|
+
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000;
|
|
17
|
+
/** Runtime-only per-turn caches: continuations and advised decisions. */
|
|
18
|
+
export const MAX_TURN_CACHE_ENTRIES = 16;
|
|
2
19
|
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
3
20
|
export const DEFAULT_MAX_TOKENS = 16_384;
|
package/extensions/context.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { Context, Message } from '@earendil-works/pi-ai';
|
|
2
|
+
import { DEFAULT_JEV_CONTEXT } from './constants';
|
|
3
|
+
import type {
|
|
4
|
+
JevContextConfig,
|
|
5
|
+
JevContextMetrics,
|
|
6
|
+
JevContextState,
|
|
7
|
+
JevTextExcerpt,
|
|
8
|
+
} from './types';
|
|
2
9
|
|
|
3
10
|
export const extractTextFromContent = (
|
|
4
11
|
content: string | Message['content'],
|
|
@@ -82,6 +89,184 @@ export const getBoundedRecentContext = (
|
|
|
82
89
|
.join('\n\n');
|
|
83
90
|
};
|
|
84
91
|
|
|
92
|
+
const textOnly = (message: Message): string =>
|
|
93
|
+
typeof message.content === 'string'
|
|
94
|
+
? message.content
|
|
95
|
+
: message.content
|
|
96
|
+
.filter((part) => part.type === 'text')
|
|
97
|
+
.map((part) => part.text)
|
|
98
|
+
.join('\n');
|
|
99
|
+
|
|
100
|
+
const utf8 = new TextEncoder();
|
|
101
|
+
|
|
102
|
+
/** Conservative Jev preflight estimate; TypeSafe does not publish its tokenizer. */
|
|
103
|
+
export const estimateJevTextTokens = (text: string): number => {
|
|
104
|
+
let ascii = 0;
|
|
105
|
+
let nonAsciiBytes = 0;
|
|
106
|
+
for (const character of text) {
|
|
107
|
+
if ((character.codePointAt(0) ?? 0) <= 0x7f) ascii += 1;
|
|
108
|
+
else nonAsciiBytes += utf8.encode(character).length;
|
|
109
|
+
}
|
|
110
|
+
return Math.ceil((ascii / 4 + nonAsciiBytes / 2) * 1.1);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Includes fixed headroom for the structured four-choice request envelope. Live
|
|
115
|
+
* `usage.input_tokens` exceeded the text estimate by 110-135 tokens on ten
|
|
116
|
+
* structured requests, so the headroom is set well above that gap.
|
|
117
|
+
*/
|
|
118
|
+
export const estimateJevRequestTokens = (serializedRequest: string): number =>
|
|
119
|
+
400 + estimateJevTextTokens(serializedRequest);
|
|
120
|
+
|
|
121
|
+
const safePrefix = (text: string, units: number): string =>
|
|
122
|
+
text.slice(0, units).replace(/[\uD800-\uDBFF]$/u, '');
|
|
123
|
+
const safeSuffix = (text: string, units: number): string =>
|
|
124
|
+
units <= 0 ? '' : text.slice(-units).replace(/^[\uDC00-\uDFFF]/u, '');
|
|
125
|
+
|
|
126
|
+
const largestFitting = (
|
|
127
|
+
text: string,
|
|
128
|
+
tokenLimit: number,
|
|
129
|
+
render: (units: number) => string,
|
|
130
|
+
): string => {
|
|
131
|
+
let low = 0;
|
|
132
|
+
let high = text.length;
|
|
133
|
+
let selected = '';
|
|
134
|
+
while (low <= high) {
|
|
135
|
+
const middle = Math.floor((low + high) / 2);
|
|
136
|
+
const candidate = render(middle);
|
|
137
|
+
if (estimateJevTextTokens(candidate) <= tokenLimit) {
|
|
138
|
+
selected = candidate;
|
|
139
|
+
low = middle + 1;
|
|
140
|
+
} else high = middle - 1;
|
|
141
|
+
}
|
|
142
|
+
return selected;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const excerpt = (text: string, tokenLimit: number): JevTextExcerpt => {
|
|
146
|
+
if (estimateJevTextTokens(text) <= tokenLimit)
|
|
147
|
+
return { text, truncated: false };
|
|
148
|
+
if (tokenLimit < estimateJevTextTokens('…'))
|
|
149
|
+
return {
|
|
150
|
+
text: largestFitting(text, tokenLimit, (units) =>
|
|
151
|
+
safePrefix(text, units),
|
|
152
|
+
),
|
|
153
|
+
truncated: true,
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
text: largestFitting(text, tokenLimit, (units) => {
|
|
157
|
+
const content = Math.max(0, units - 1);
|
|
158
|
+
const head = Math.ceil(content / 2);
|
|
159
|
+
return `${safePrefix(text, head)}…${safeSuffix(text, content - head)}`;
|
|
160
|
+
}),
|
|
161
|
+
truncated: true,
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Fixed structural selection, not intent scoring. Only selected text reaches Jev. */
|
|
166
|
+
export const buildJevContext = (
|
|
167
|
+
context: Context,
|
|
168
|
+
maxTokens: number,
|
|
169
|
+
options: JevContextConfig = DEFAULT_JEV_CONTEXT,
|
|
170
|
+
): { state: JevContextState; metrics: JevContextMetrics } => {
|
|
171
|
+
const budget = Number.isFinite(maxTokens)
|
|
172
|
+
? Math.max(0, Math.floor(maxTokens))
|
|
173
|
+
: 0;
|
|
174
|
+
const latest = context.messages.findLastIndex(
|
|
175
|
+
(message) => message.role === 'user',
|
|
176
|
+
);
|
|
177
|
+
const current = context.messages[latest];
|
|
178
|
+
const state: JevContextState = {
|
|
179
|
+
currentRequest: excerpt(current ? textOnly(current) : '', budget),
|
|
180
|
+
recentDialogue: [],
|
|
181
|
+
recentToolEvidence: [],
|
|
182
|
+
};
|
|
183
|
+
let remaining = budget - estimateJevTextTokens(state.currentRequest.text);
|
|
184
|
+
const turns: { start: number; end: number }[] = [];
|
|
185
|
+
let end = latest;
|
|
186
|
+
for (
|
|
187
|
+
let index = latest - 1;
|
|
188
|
+
index >= 0 && turns.length < Math.max(1, options.previousTurns);
|
|
189
|
+
index--
|
|
190
|
+
) {
|
|
191
|
+
if (context.messages[index]?.role !== 'user') continue;
|
|
192
|
+
turns.push({ start: index, end });
|
|
193
|
+
end = index;
|
|
194
|
+
}
|
|
195
|
+
const dialogue: { role: 'user' | 'assistant'; text: string; turn: number }[] =
|
|
196
|
+
[];
|
|
197
|
+
for (const [turn, bounds] of turns
|
|
198
|
+
.slice(0, options.previousTurns)
|
|
199
|
+
.entries()) {
|
|
200
|
+
const messages = context.messages.slice(bounds.start, bounds.end);
|
|
201
|
+
const user = messages[0];
|
|
202
|
+
const answer = messages.findLast(
|
|
203
|
+
(message) =>
|
|
204
|
+
message.role === 'assistant' && textOnly(message).trim().length > 0,
|
|
205
|
+
);
|
|
206
|
+
// Newest turn first for allocation; render the final payload chronologically.
|
|
207
|
+
if (answer)
|
|
208
|
+
dialogue.push({ role: 'assistant', text: textOnly(answer), turn });
|
|
209
|
+
if (user && textOnly(user).trim())
|
|
210
|
+
dialogue.push({ role: 'user', text: textOnly(user), turn });
|
|
211
|
+
}
|
|
212
|
+
let historyBudget = Math.min(remaining, options.maxHistoryTokens);
|
|
213
|
+
const includedTurns = new Set<number>();
|
|
214
|
+
for (const [index, entry] of dialogue.entries()) {
|
|
215
|
+
const limit = Math.floor(historyBudget / (dialogue.length - index));
|
|
216
|
+
if (limit < 1) break;
|
|
217
|
+
const selected = excerpt(entry.text, limit);
|
|
218
|
+
state.recentDialogue.unshift({ role: entry.role, ...selected });
|
|
219
|
+
includedTurns.add(entry.turn);
|
|
220
|
+
const selectedTokens = estimateJevTextTokens(selected.text);
|
|
221
|
+
historyBudget -= selectedTokens;
|
|
222
|
+
remaining -= selectedTokens;
|
|
223
|
+
}
|
|
224
|
+
const previous = turns[0];
|
|
225
|
+
if (
|
|
226
|
+
options.toolResults !== 'none' &&
|
|
227
|
+
previous &&
|
|
228
|
+
remaining > 0 &&
|
|
229
|
+
options.maxToolTokens > 0
|
|
230
|
+
) {
|
|
231
|
+
const tool = context.messages
|
|
232
|
+
.slice(previous.start, previous.end)
|
|
233
|
+
.findLast((message) => message.role === 'toolResult');
|
|
234
|
+
if (
|
|
235
|
+
tool?.role === 'toolResult' &&
|
|
236
|
+
(options.toolResults === 'last' || tool.isError === true)
|
|
237
|
+
) {
|
|
238
|
+
const text = textOnly(tool);
|
|
239
|
+
if (text.trim())
|
|
240
|
+
state.recentToolEvidence.push({
|
|
241
|
+
...excerpt(text, Math.min(remaining, options.maxToolTokens)),
|
|
242
|
+
isError: tool.isError === true,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const all = [
|
|
247
|
+
state.currentRequest,
|
|
248
|
+
...state.recentDialogue,
|
|
249
|
+
...state.recentToolEvidence,
|
|
250
|
+
];
|
|
251
|
+
return {
|
|
252
|
+
state,
|
|
253
|
+
metrics: {
|
|
254
|
+
currentRequestTokens: estimateJevTextTokens(state.currentRequest.text),
|
|
255
|
+
historyTokens: state.recentDialogue.reduce(
|
|
256
|
+
(sum, entry) => sum + estimateJevTextTokens(entry.text),
|
|
257
|
+
0,
|
|
258
|
+
),
|
|
259
|
+
toolTokens: state.recentToolEvidence.reduce(
|
|
260
|
+
(sum, entry) => sum + estimateJevTextTokens(entry.text),
|
|
261
|
+
0,
|
|
262
|
+
),
|
|
263
|
+
historyTurns: includedTurns.size,
|
|
264
|
+
toolResults: state.recentToolEvidence.length,
|
|
265
|
+
truncatedBlocks: all.filter((entry) => entry.truncated).length,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
85
270
|
export const hasImageAttachment = (context: Context): boolean =>
|
|
86
271
|
context.messages.some(
|
|
87
272
|
(message) =>
|