@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.
@@ -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 { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
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: mergeRawValue(base.jev, override.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
- maxStateChars: 12000,
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.maxStateChars !== 'number' ||
433
- !Number.isInteger(value.maxStateChars) ||
434
- value.maxStateChars < 1 ||
435
- value.maxStateChars > 12000 ||
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
- maxStateChars: value.maxStateChars,
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
- classifierModel = { model: resolved.canonicalRef, thinking };
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
  }
@@ -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;
@@ -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) =>