@alexeiled/pi-model-router 0.5.0 → 0.5.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.
- package/CHANGELOG.md +13 -1
- package/README.md +18 -2
- package/extensions/commands.ts +14 -9
- package/extensions/config.ts +23 -7
- package/extensions/constants.ts +0 -46
- package/extensions/index.ts +9 -7
- package/extensions/provider.ts +117 -125
- package/extensions/routing.ts +21 -23
- package/extensions/state.ts +69 -12
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.
|
|
3
|
+
## 0.5.1 — 2026-09-20
|
|
4
|
+
|
|
5
|
+
- Delegate generation and classification through Pi's native model registry instead of duplicating auth/dispatch logic. Cover keyless and headers-only auth, native providers and credential URLs with in-memory SDK integration tests.
|
|
6
|
+
- Retry only before output; preserve aborts and partial output, reject unterminated streams, and record the actual fallback model.
|
|
7
|
+
- Use each attempted model's context limit and trim complete turns without discarding system messages or orphaning tool results.
|
|
8
|
+
- Resolve classifier choices against partial profiles, bound classifier requests, and reject malformed/error responses.
|
|
9
|
+
- Deep-copy and validate persisted state; reset snapshot deduplication across branches.
|
|
10
|
+
- Validate rule keywords and malformed profiles; avoid inherited-name lookups and respect non-reasoning tier declarations.
|
|
11
|
+
- Reject invalid debug/widget options, fix thinking completions, and avoid success notifications after failed switches.
|
|
12
|
+
- Replace sleep-based stream tests with real event streams; share typed fixtures and keep test helpers out of npm artifacts.
|
|
13
|
+
- Fail closed on npm registry lookup errors other than a missing version. Document the release and provenance verification procedure.
|
|
14
|
+
|
|
15
|
+
## 0.5.0 — 2026-09-20
|
|
4
16
|
|
|
5
17
|
This is the first release of the independently maintained `@alexeiled/pi-model-router` fork.
|
|
6
18
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# pi-model-router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/alexei-led/pi-model-router/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@alexeiled/pi-model-router)
|
|
5
|
+
[](https://www.npmjs.com/package/@alexeiled/pi-model-router)
|
|
6
|
+
[](https://github.com/alexei-led/pi-model-router/releases)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
9
|
+
|
|
10
|
+
Per-turn model router for [Pi](https://github.com/earendil-works/pi/tree/main/packages/coding-agent). Selects high, medium or low-tier models using task intent, a soft budget policy and custom rules, while keeping the selected `router/<profile>` model stable.
|
|
4
11
|
|
|
5
12
|
> **Independent fork:** This project is an independently maintained fork of [yeliu84/pi-model-router](https://github.com/yeliu84/pi-model-router), originally created by Ye Liu. It is not an official upstream release. The original MIT license and copyright notice are preserved.
|
|
6
13
|
|
|
@@ -82,6 +89,15 @@ Or load directly for one run:
|
|
|
82
89
|
pi -e ./extensions/index.ts
|
|
83
90
|
```
|
|
84
91
|
|
|
92
|
+
## Reliability
|
|
93
|
+
|
|
94
|
+
- Both generation and classification use Pi's provider registry, including native/custom providers and credential-specific URLs.
|
|
95
|
+
- Fallbacks run only before content is emitted; cancellation does not retry.
|
|
96
|
+
- Classifier requests use isolated context, a 10-second cancellation deadline and a 256-token output limit. Failures retain local routing.
|
|
97
|
+
- Context trimming preserves system instructions and whole active tool turns. It is a text estimate, not a guarantee that images or a large active turn fit.
|
|
98
|
+
|
|
99
|
+
See [architecture](https://github.com/alexei-led/pi-model-router/blob/main/docs/ARCHITECTURE.md) and [release procedure](https://github.com/alexei-led/pi-model-router/blob/main/docs/RELEASING.md).
|
|
100
|
+
|
|
85
101
|
## Configuration
|
|
86
102
|
|
|
87
103
|
Copy the example config to one of:
|
|
@@ -112,7 +128,7 @@ The extension stores the last selected profile in `~/.pi/agent/model-router-stat
|
|
|
112
128
|
| Field | Description |
|
|
113
129
|
| ----------------------- | --------------------------------------------------------------------------------- |
|
|
114
130
|
| `classifierModel` | (Optional) Model used to categorize intent. Supports model aliases. If omitted, fast heuristics are used. |
|
|
115
|
-
| `maxSessionBudget` | (Optional)
|
|
131
|
+
| `maxSessionBudget` | (Optional) Soft generation-cost threshold in USD. Downgrades high to medium, or low if medium is absent. Not a spending cap; classifier cost is excluded. |
|
|
116
132
|
| `phaseBias` | (0.0 - 1.0) Stickiness of the current phase. Higher = more stable. Default `0.5`. |
|
|
117
133
|
| `rules` | List of custom keyword rules (e.g. `{ "matches": "deploy", "tier": "high" }`). |
|
|
118
134
|
| `models` | (Optional) Map of model aliases to definitions with `model`, `contextWindow`, `maxTokens`. |
|
package/extensions/commands.ts
CHANGED
|
@@ -136,10 +136,6 @@ export const registerCommands = (
|
|
|
136
136
|
];
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
if (levelValues.includes(args[0])) {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
139
|
if ((tierValues as string[]).includes(args[0])) {
|
|
144
140
|
const tier = args[0];
|
|
145
141
|
const levelPrefix = args[1] ?? '';
|
|
@@ -443,6 +439,10 @@ export const registerCommands = (
|
|
|
443
439
|
return;
|
|
444
440
|
}
|
|
445
441
|
const cmd = args[0]?.toLowerCase();
|
|
442
|
+
if (cmd && !['on', 'off', 'toggle'].includes(cmd)) {
|
|
443
|
+
ctx.ui.notify('Usage: /router widget <on|off|toggle>', 'error');
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
446
|
if (cmd === 'on') state.widgetEnabled = true;
|
|
447
447
|
else if (cmd === 'off') state.widgetEnabled = false;
|
|
448
448
|
else state.widgetEnabled = !state.widgetEnabled;
|
|
@@ -460,6 +460,10 @@ export const registerCommands = (
|
|
|
460
460
|
return;
|
|
461
461
|
}
|
|
462
462
|
const cmd = args[0]?.toLowerCase();
|
|
463
|
+
if (cmd && !['on', 'off', 'toggle', 'clear', 'show'].includes(cmd)) {
|
|
464
|
+
ctx.ui.notify('Usage: /router debug <on|off|toggle|show|clear>', 'error');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
463
467
|
if (cmd === 'on') state.debugEnabled = true;
|
|
464
468
|
else if (cmd === 'off') state.debugEnabled = false;
|
|
465
469
|
else if (cmd === 'clear') state.debugHistory.length = 0;
|
|
@@ -656,11 +660,12 @@ export const registerCommands = (
|
|
|
656
660
|
);
|
|
657
661
|
return;
|
|
658
662
|
}
|
|
659
|
-
await actions.switchToRouterProfile(subcommand, ctx)
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
663
|
+
if (await actions.switchToRouterProfile(subcommand, ctx)) {
|
|
664
|
+
ctx.ui.notify(
|
|
665
|
+
`Router enabled with profile: ${state.selectedProfile}`,
|
|
666
|
+
'info',
|
|
667
|
+
);
|
|
668
|
+
}
|
|
664
669
|
} else {
|
|
665
670
|
ctx.ui.notify(
|
|
666
671
|
`Unknown router subcommand: ${subcommand}. Try /router help`,
|
package/extensions/config.ts
CHANGED
|
@@ -82,7 +82,8 @@ export const resolveModelRef = (
|
|
|
82
82
|
ref: string,
|
|
83
83
|
models: Record<string, ModelDefinition> | undefined,
|
|
84
84
|
): { canonicalRef: string; definition?: ModelDefinition } => {
|
|
85
|
-
const definition =
|
|
85
|
+
const definition =
|
|
86
|
+
models && Object.hasOwn(models, ref) ? models[ref] : undefined;
|
|
86
87
|
if (definition) {
|
|
87
88
|
return { canonicalRef: definition.model, definition };
|
|
88
89
|
}
|
|
@@ -104,8 +105,13 @@ export const mergeConfig = (
|
|
|
104
105
|
override: Partial<RouterConfig>,
|
|
105
106
|
): RouterConfig => {
|
|
106
107
|
const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
|
|
107
|
-
for (const [name, profile] of Object.entries(
|
|
108
|
-
|
|
108
|
+
for (const [name, profile] of Object.entries(
|
|
109
|
+
isObjectRecord(override.profiles) ? override.profiles : {},
|
|
110
|
+
)) {
|
|
111
|
+
if (!isObjectRecord(profile) || name === '__proto__') continue;
|
|
112
|
+
const existing = Object.hasOwn(mergedProfiles, name)
|
|
113
|
+
? mergedProfiles[name]
|
|
114
|
+
: undefined;
|
|
109
115
|
const nextProfile = profile as Partial<RouterProfile>;
|
|
110
116
|
mergedProfiles[name] = {
|
|
111
117
|
high: mergeTier(existing?.high, nextProfile.high),
|
|
@@ -116,7 +122,7 @@ export const mergeConfig = (
|
|
|
116
122
|
|
|
117
123
|
const mergedModels: Record<string, ModelDefinition> = {
|
|
118
124
|
...(base.models ?? {}),
|
|
119
|
-
...(override.models
|
|
125
|
+
...(isObjectRecord(override.models) ? override.models : {}),
|
|
120
126
|
};
|
|
121
127
|
|
|
122
128
|
return {
|
|
@@ -160,6 +166,7 @@ export const normalizeModelsMap = (
|
|
|
160
166
|
if (!raw || !isObjectRecord(raw)) return result;
|
|
161
167
|
|
|
162
168
|
for (const [alias, entry] of Object.entries(raw)) {
|
|
169
|
+
if (alias === '__proto__') continue;
|
|
163
170
|
if (!isObjectRecord(entry)) {
|
|
164
171
|
warnings.push(
|
|
165
172
|
`Ignored invalid model definition "${alias}": expected an object.`,
|
|
@@ -331,6 +338,7 @@ export const normalizeTierConfig = (
|
|
|
331
338
|
const resolvedThinkingLevels: ThinkingLevel[] = [...baseThinkingLevels];
|
|
332
339
|
if (
|
|
333
340
|
!explicitThinkingLevels &&
|
|
341
|
+
effectiveReasoning !== false &&
|
|
334
342
|
thinking !== 'off' &&
|
|
335
343
|
!resolvedThinkingLevels.includes(thinking)
|
|
336
344
|
) {
|
|
@@ -363,7 +371,10 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
363
371
|
|
|
364
372
|
const normalizedProfiles: Record<string, RouterProfile> = {};
|
|
365
373
|
|
|
366
|
-
for (const [name, profile] of Object.entries(
|
|
374
|
+
for (const [name, profile] of Object.entries(
|
|
375
|
+
isObjectRecord(raw.profiles) ? raw.profiles : {},
|
|
376
|
+
)) {
|
|
377
|
+
if (name === '__proto__') continue;
|
|
367
378
|
const high = normalizeTierConfig(
|
|
368
379
|
profile?.high,
|
|
369
380
|
name,
|
|
@@ -411,7 +422,12 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
411
422
|
const matches = rule.matches;
|
|
412
423
|
const tier = rule.tier;
|
|
413
424
|
if (
|
|
414
|
-
(typeof matches === 'string'
|
|
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
|
+
))) &&
|
|
415
431
|
isRouterTier(tier)
|
|
416
432
|
) {
|
|
417
433
|
rules.push({
|
|
@@ -518,7 +534,7 @@ export const resolveProfileName = (
|
|
|
518
534
|
config: RouterConfig,
|
|
519
535
|
requested?: string,
|
|
520
536
|
): string | undefined => {
|
|
521
|
-
if (requested && config.profiles
|
|
537
|
+
if (requested && Object.hasOwn(config.profiles, requested)) {
|
|
522
538
|
return requested;
|
|
523
539
|
}
|
|
524
540
|
return undefined;
|
package/extensions/constants.ts
CHANGED
|
@@ -1,49 +1,3 @@
|
|
|
1
1
|
export const MAX_DEBUG_HISTORY = 12;
|
|
2
2
|
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
3
3
|
export const DEFAULT_MAX_TOKENS = 16_384;
|
|
4
|
-
|
|
5
|
-
const AUTH_HEADERS = new Set([
|
|
6
|
-
'authorization',
|
|
7
|
-
'x-api-key',
|
|
8
|
-
'cf-aig-authorization',
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
export interface RegistryWithProviderAuth {
|
|
12
|
-
getProviderAuth?: (
|
|
13
|
-
provider: string,
|
|
14
|
-
) => Promise<{ auth: { baseUrl?: string } } | undefined>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const hasUsableRequestAuth = (auth: {
|
|
18
|
-
apiKey?: string;
|
|
19
|
-
headers?: Record<string, string | null | undefined>;
|
|
20
|
-
}): boolean => {
|
|
21
|
-
if (typeof auth.apiKey === 'string' && auth.apiKey.trim().length > 0) {
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return Object.entries(auth.headers ?? {}).some(
|
|
26
|
-
([name, value]) =>
|
|
27
|
-
AUTH_HEADERS.has(name.toLowerCase()) &&
|
|
28
|
-
typeof value === 'string' &&
|
|
29
|
-
value.trim().length > 0,
|
|
30
|
-
);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export const resolveDelegatedModel = async <
|
|
34
|
-
TModel extends { provider: string; baseUrl: string },
|
|
35
|
-
>(
|
|
36
|
-
registry: RegistryWithProviderAuth,
|
|
37
|
-
model: TModel,
|
|
38
|
-
): Promise<TModel> => {
|
|
39
|
-
try {
|
|
40
|
-
const providerAuth = await registry.getProviderAuth?.(model.provider);
|
|
41
|
-
const authBaseUrl = providerAuth?.auth.baseUrl;
|
|
42
|
-
if (authBaseUrl && authBaseUrl !== model.baseUrl) {
|
|
43
|
-
return { ...model, baseUrl: authBaseUrl };
|
|
44
|
-
}
|
|
45
|
-
} catch {
|
|
46
|
-
// Older Pi versions and unavailable credentials use the model's static URL.
|
|
47
|
-
}
|
|
48
|
-
return model;
|
|
49
|
-
};
|
package/extensions/index.ts
CHANGED
|
@@ -199,7 +199,6 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
199
199
|
|
|
200
200
|
// Ensure the provider is registered with current capacities for this profile
|
|
201
201
|
actions.registerRouterProvider();
|
|
202
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
203
202
|
|
|
204
203
|
const routerModel = ctx.modelRegistry.find('router', profileName);
|
|
205
204
|
if (!routerModel) {
|
|
@@ -284,6 +283,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
284
283
|
ctx: ExtensionContext,
|
|
285
284
|
startReason: SessionStartEvent['reason'],
|
|
286
285
|
) => {
|
|
286
|
+
lastPersistedSnapshot = undefined;
|
|
287
287
|
ignoreStartupThinkingEvent =
|
|
288
288
|
startReason === 'startup' ||
|
|
289
289
|
startReason === 'new' ||
|
|
@@ -295,9 +295,6 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
295
295
|
const hasExplicitStartupModel =
|
|
296
296
|
startReason === 'startup' && hasExplicitCliModel();
|
|
297
297
|
|
|
298
|
-
// Give the registry a moment to synchronize after re-registration
|
|
299
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
300
|
-
|
|
301
298
|
routerEnabled = ctx.model?.provider === 'router';
|
|
302
299
|
selectedProfile =
|
|
303
300
|
ctx.model?.provider === 'router'
|
|
@@ -342,7 +339,10 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
342
339
|
Object.assign(pinnedTierByProfile, savedState.pinByProfile);
|
|
343
340
|
}
|
|
344
341
|
if (savedState.thinkingByProfile) {
|
|
345
|
-
Object.assign(
|
|
342
|
+
Object.assign(
|
|
343
|
+
thinkingByProfile,
|
|
344
|
+
structuredClone(savedState.thinkingByProfile),
|
|
345
|
+
);
|
|
346
346
|
}
|
|
347
347
|
if (savedState.pinTier && selectedProfile) {
|
|
348
348
|
pinnedTierByProfile[selectedProfile] = savedState.pinTier;
|
|
@@ -350,12 +350,14 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
350
350
|
debugEnabled = savedState.debugEnabled ?? debugEnabled;
|
|
351
351
|
widgetEnabled = savedState.widgetEnabled ?? widgetEnabled;
|
|
352
352
|
debugHistory = savedState.debugHistory
|
|
353
|
-
?
|
|
353
|
+
? structuredClone(savedState.debugHistory).slice(-MAX_DEBUG_HISTORY)
|
|
354
354
|
: [];
|
|
355
355
|
if (!hasExplicitStartupModel) {
|
|
356
356
|
lastNonRouterModel =
|
|
357
357
|
savedState.lastNonRouterModel ?? lastNonRouterModel;
|
|
358
|
-
lastDecision = savedState.lastDecision
|
|
358
|
+
lastDecision = savedState.lastDecision
|
|
359
|
+
? structuredClone(savedState.lastDecision)
|
|
360
|
+
: undefined;
|
|
359
361
|
}
|
|
360
362
|
accumulatedCost = savedState.accumulatedCost ?? 0;
|
|
361
363
|
} else if (
|
package/extensions/provider.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
1
2
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
2
3
|
import {
|
|
3
4
|
type Api,
|
|
4
5
|
type AssistantMessage,
|
|
6
|
+
type AssistantMessageEvent,
|
|
5
7
|
type AssistantMessageEventStream,
|
|
6
8
|
type Context,
|
|
7
9
|
createAssistantMessageEventStream,
|
|
8
10
|
type Model,
|
|
9
|
-
normalizeContext,
|
|
10
11
|
type SimpleStreamOptions,
|
|
11
|
-
type TranscriptContext,
|
|
12
12
|
} from '@earendil-works/pi-ai';
|
|
13
|
-
import { streamSimple } from '@earendil-works/pi-ai/compat';
|
|
14
13
|
import type {
|
|
15
14
|
ExtensionAPI,
|
|
16
15
|
ExtensionContext,
|
|
@@ -25,13 +24,7 @@ import {
|
|
|
25
24
|
resolveContextWindow,
|
|
26
25
|
resolveMaxTokens,
|
|
27
26
|
} from './config';
|
|
28
|
-
import {
|
|
29
|
-
DEFAULT_CONTEXT_WINDOW,
|
|
30
|
-
DEFAULT_MAX_TOKENS,
|
|
31
|
-
hasUsableRequestAuth,
|
|
32
|
-
type RegistryWithProviderAuth,
|
|
33
|
-
resolveDelegatedModel,
|
|
34
|
-
} from './constants';
|
|
27
|
+
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
|
|
35
28
|
import type {
|
|
36
29
|
RouterConfig,
|
|
37
30
|
RouterPinByProfile,
|
|
@@ -44,17 +37,6 @@ const REGISTRY_WAIT_TIMEOUT_MS = 5000;
|
|
|
44
37
|
const REGISTRY_WAIT_INITIAL_DELAY_MS = 50;
|
|
45
38
|
const REGISTRY_WAIT_MAX_DELAY_MS = 500;
|
|
46
39
|
|
|
47
|
-
type ProviderAwareRegistry = ExtensionContext['modelRegistry'] & {
|
|
48
|
-
getRegisteredProviderConfig?: (provider: string) => {
|
|
49
|
-
api?: Api;
|
|
50
|
-
streamSimple?: (
|
|
51
|
-
model: Model<Api>,
|
|
52
|
-
context: TranscriptContext,
|
|
53
|
-
options?: SimpleStreamOptions,
|
|
54
|
-
) => AssistantMessageEventStream;
|
|
55
|
-
};
|
|
56
|
-
};
|
|
57
|
-
|
|
58
40
|
/**
|
|
59
41
|
* Wait for the model registry to become available with exponential backoff.
|
|
60
42
|
* This handles the race condition where subagents (e.g. from pi-dynamic-workflows)
|
|
@@ -67,15 +49,21 @@ export const waitForRegistry = async (
|
|
|
67
49
|
| undefined;
|
|
68
50
|
},
|
|
69
51
|
timeoutMs: number = REGISTRY_WAIT_TIMEOUT_MS,
|
|
52
|
+
signal?: AbortSignal,
|
|
70
53
|
): Promise<ExtensionContext['modelRegistry'] | undefined> => {
|
|
54
|
+
signal?.throwIfAborted();
|
|
71
55
|
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
72
56
|
|
|
73
57
|
const start = Date.now();
|
|
74
|
-
let
|
|
58
|
+
let interval = REGISTRY_WAIT_INITIAL_DELAY_MS;
|
|
75
59
|
while (Date.now() - start < timeoutMs) {
|
|
76
|
-
await
|
|
60
|
+
await delay(
|
|
61
|
+
Math.min(interval, timeoutMs - (Date.now() - start)),
|
|
62
|
+
undefined,
|
|
63
|
+
{ signal },
|
|
64
|
+
);
|
|
77
65
|
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
78
|
-
|
|
66
|
+
interval = Math.min(interval * 2, REGISTRY_WAIT_MAX_DELAY_MS);
|
|
79
67
|
}
|
|
80
68
|
return undefined;
|
|
81
69
|
};
|
|
@@ -86,6 +74,7 @@ import {
|
|
|
86
74
|
extractTextFromContent,
|
|
87
75
|
hasImageAttachment,
|
|
88
76
|
phaseForTier,
|
|
77
|
+
resolveAvailableTier,
|
|
89
78
|
runClassifier,
|
|
90
79
|
} from './routing';
|
|
91
80
|
|
|
@@ -120,7 +109,7 @@ const estimateTokens = (text: string): number => Math.ceil(text.length / 3);
|
|
|
120
109
|
|
|
121
110
|
/**
|
|
122
111
|
* Truncate context to fit within a target token limit by removing oldest messages.
|
|
123
|
-
*
|
|
112
|
+
* Preserves the system prompt and the complete latest user/tool turn.
|
|
124
113
|
*/
|
|
125
114
|
const truncateContext = (context: Context, limit: number): Context => {
|
|
126
115
|
const messages = [...context.messages];
|
|
@@ -139,24 +128,23 @@ const truncateContext = (context: Context, limit: number): Context => {
|
|
|
139
128
|
|
|
140
129
|
if (totalTokens <= limit) return context;
|
|
141
130
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// Keep shifting oldest messages from the start of the list
|
|
147
|
-
let activeMessagesTokensSum = messageTokens.reduce((sum, t) => sum + t, 0);
|
|
148
|
-
|
|
131
|
+
// Drop only complete turns. Splitting an assistant/tool-result pair corrupts transcripts.
|
|
132
|
+
// This text estimate cannot guarantee a fit for images/tools or one oversized active turn.
|
|
133
|
+
let remaining = totalTokens;
|
|
149
134
|
let startIndex = 0;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
activeMessagesTokensSum -= messageTokens[startIndex];
|
|
155
|
-
startIndex++;
|
|
135
|
+
for (let i = 1; i < messages.length && remaining > limit; i++) {
|
|
136
|
+
if (messages[i].role !== 'user') continue;
|
|
137
|
+
while (startIndex < i) remaining -= messageTokens[startIndex++];
|
|
156
138
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
139
|
+
return {
|
|
140
|
+
...context,
|
|
141
|
+
messages: [
|
|
142
|
+
...messages
|
|
143
|
+
.slice(0, startIndex)
|
|
144
|
+
.filter((message) => message.role === 'system'),
|
|
145
|
+
...messages.slice(startIndex),
|
|
146
|
+
],
|
|
147
|
+
};
|
|
160
148
|
};
|
|
161
149
|
|
|
162
150
|
const supportsReasoning = (
|
|
@@ -219,8 +207,8 @@ export const registerRouterProvider = (
|
|
|
219
207
|
// Report the MAX context window and max output tokens across all tiers.
|
|
220
208
|
// The honesty check + truncateContext handles the case where the
|
|
221
209
|
// actually routed model is smaller.
|
|
222
|
-
let maxContextWindow =
|
|
223
|
-
let maxMaxTokens =
|
|
210
|
+
let maxContextWindow = 0;
|
|
211
|
+
let maxMaxTokens = 0;
|
|
224
212
|
for (const tier of ROUTER_TIERS) {
|
|
225
213
|
if (!profile[tier]) continue;
|
|
226
214
|
const cw = resolveContextWindow(
|
|
@@ -252,14 +240,12 @@ export const registerRouterProvider = (
|
|
|
252
240
|
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
253
241
|
input: ['text', 'image'] as ('text' | 'image')[],
|
|
254
242
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
255
|
-
contextWindow: maxContextWindow,
|
|
256
|
-
maxTokens: maxMaxTokens,
|
|
243
|
+
contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
|
|
244
|
+
maxTokens: maxMaxTokens || DEFAULT_MAX_TOKENS,
|
|
257
245
|
};
|
|
258
246
|
});
|
|
259
247
|
|
|
260
|
-
const modelsKey = modelDefinitions
|
|
261
|
-
.map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`)
|
|
262
|
-
.join(',');
|
|
248
|
+
const modelsKey = JSON.stringify(modelDefinitions);
|
|
263
249
|
if (state.lastRegisteredModels === modelsKey) return;
|
|
264
250
|
|
|
265
251
|
pi.registerProvider('router', {
|
|
@@ -275,6 +261,7 @@ export const registerRouterProvider = (
|
|
|
275
261
|
const stream = createAssistantMessageEventStream();
|
|
276
262
|
|
|
277
263
|
(async () => {
|
|
264
|
+
let partialMessage: AssistantMessage | undefined;
|
|
278
265
|
try {
|
|
279
266
|
// Wait for the router to be fully initialized (session_start sets currentModelRegistry).
|
|
280
267
|
// This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
|
|
@@ -282,6 +269,7 @@ export const registerRouterProvider = (
|
|
|
282
269
|
const registry = await waitForRegistry(
|
|
283
270
|
state,
|
|
284
271
|
state.registryTimeoutMs,
|
|
272
|
+
options?.signal,
|
|
285
273
|
);
|
|
286
274
|
if (!registry) {
|
|
287
275
|
throw new Error(
|
|
@@ -325,15 +313,20 @@ export const registerRouterProvider = (
|
|
|
325
313
|
state.currentConfig.classifierModel.model,
|
|
326
314
|
registry,
|
|
327
315
|
context,
|
|
328
|
-
state.lastDecision?.
|
|
316
|
+
state.lastDecision?.profile === model.id
|
|
317
|
+
? state.lastDecision.phase
|
|
318
|
+
: undefined,
|
|
329
319
|
state.currentConfig.classifierModel.thinking,
|
|
320
|
+
options?.signal,
|
|
330
321
|
);
|
|
322
|
+
options?.signal?.throwIfAborted();
|
|
331
323
|
if (classifierResult) {
|
|
324
|
+
const tier = resolveAvailableTier(profile, classifierResult.tier);
|
|
332
325
|
decision = buildRoutingDecision(
|
|
333
326
|
model.id,
|
|
334
327
|
profile,
|
|
335
|
-
|
|
336
|
-
phaseForTier(
|
|
328
|
+
tier,
|
|
329
|
+
phaseForTier(tier),
|
|
337
330
|
`Classifier: ${classifierResult.reasoning}`,
|
|
338
331
|
state.thinkingByProfile[model.id],
|
|
339
332
|
true,
|
|
@@ -446,13 +439,16 @@ export const registerRouterProvider = (
|
|
|
446
439
|
if (imageAttached) {
|
|
447
440
|
modelsToTry = modelsToTry.filter(checkModelSupportsImage);
|
|
448
441
|
if (modelsToTry.length === 0) {
|
|
449
|
-
|
|
442
|
+
throw new Error(
|
|
443
|
+
'No configured model supports image attachments.',
|
|
444
|
+
);
|
|
450
445
|
}
|
|
451
446
|
}
|
|
452
447
|
let lastError: unknown;
|
|
453
448
|
let success = false;
|
|
454
449
|
|
|
455
450
|
for (let i = 0; i < modelsToTry.length; i++) {
|
|
451
|
+
options?.signal?.throwIfAborted();
|
|
456
452
|
const modelRef = modelsToTry[i];
|
|
457
453
|
const { provider: targetProvider, modelId: targetModelId } =
|
|
458
454
|
parseCanonicalModelRef(modelRef);
|
|
@@ -467,31 +463,14 @@ export const registerRouterProvider = (
|
|
|
467
463
|
continue;
|
|
468
464
|
}
|
|
469
465
|
|
|
470
|
-
|
|
471
|
-
if (!auth.ok || !hasUsableRequestAuth(auth)) {
|
|
472
|
-
lastError = new Error(
|
|
473
|
-
auth.ok
|
|
474
|
-
? `No API key or authentication headers for routed model: ${targetProvider}/${targetModelId}`
|
|
475
|
-
: `Auth failed for routed model: ${targetProvider}/${targetModelId}: ${auth.error}`,
|
|
476
|
-
);
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
const apiKey = auth.apiKey;
|
|
480
|
-
const headers = auth.headers;
|
|
481
|
-
const requestModel = await resolveDelegatedModel(
|
|
482
|
-
registry as unknown as RegistryWithProviderAuth,
|
|
483
|
-
targetModel,
|
|
484
|
-
);
|
|
485
|
-
|
|
466
|
+
let contentReceived = false;
|
|
486
467
|
try {
|
|
487
468
|
// HONESTY CHECK & AUTO-TRUNCATION
|
|
488
469
|
// If the picked model has a smaller context than what we reported, truncate now.
|
|
489
470
|
let effectiveContext = context;
|
|
490
|
-
const targetLimit =
|
|
491
|
-
|
|
492
|
-
profile,
|
|
493
|
-
registry,
|
|
494
|
-
);
|
|
471
|
+
const targetLimit =
|
|
472
|
+
targetModel.contextWindow ??
|
|
473
|
+
resolveContextWindow(decision.tier, profile, registry);
|
|
495
474
|
if (
|
|
496
475
|
model.contextWindow !== undefined &&
|
|
497
476
|
targetLimit < model.contextWindow
|
|
@@ -534,42 +513,42 @@ export const registerRouterProvider = (
|
|
|
534
513
|
// Stale extension context — skip non-critical UI updates.
|
|
535
514
|
}
|
|
536
515
|
|
|
537
|
-
//
|
|
538
|
-
const {
|
|
539
|
-
|
|
516
|
+
// Router credentials must not override the concrete provider's request auth.
|
|
517
|
+
const {
|
|
518
|
+
reasoning: _piReasoning,
|
|
519
|
+
apiKey: _routerKey,
|
|
520
|
+
headers: _routerHeaders,
|
|
521
|
+
env: _routerEnv,
|
|
522
|
+
...delegationOptions
|
|
523
|
+
} = options ?? {};
|
|
540
524
|
|
|
541
525
|
const delegatedOptions = {
|
|
542
526
|
...delegationOptions,
|
|
543
|
-
apiKey,
|
|
544
|
-
headers,
|
|
545
527
|
...(delegatedReasoning
|
|
546
528
|
? { reasoning: delegatedReasoning }
|
|
547
529
|
: {}),
|
|
548
530
|
};
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
);
|
|
565
|
-
|
|
566
|
-
let contentReceived = false;
|
|
531
|
+
// Pi owns request-time auth, custom/native providers, URLs and transcript normalization.
|
|
532
|
+
const delegatedStream = registry.streamSimple(
|
|
533
|
+
targetModel,
|
|
534
|
+
effectiveContext,
|
|
535
|
+
delegatedOptions,
|
|
536
|
+
);
|
|
537
|
+
let terminalReceived = false;
|
|
538
|
+
const pendingEvents: AssistantMessageEvent[] = [];
|
|
539
|
+
const recordTarget = () => {
|
|
540
|
+
decision.targetProvider = targetProvider;
|
|
541
|
+
decision.targetModelId = targetModelId;
|
|
542
|
+
decision.targetLabel = modelRef;
|
|
543
|
+
decision.thinking = delegatedReasoning ?? 'off';
|
|
544
|
+
decision.isFallback = i > 0;
|
|
545
|
+
};
|
|
567
546
|
for await (const event of delegatedStream) {
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
547
|
+
if (
|
|
548
|
+
event.type === 'error' &&
|
|
549
|
+
event.reason !== 'aborted' &&
|
|
550
|
+
!contentReceived
|
|
551
|
+
) {
|
|
573
552
|
const errorMessage =
|
|
574
553
|
'error' in event &&
|
|
575
554
|
event.error &&
|
|
@@ -587,13 +566,37 @@ export const registerRouterProvider = (
|
|
|
587
566
|
event.type === 'thinking_delta' ||
|
|
588
567
|
event.type === 'toolcall_delta' ||
|
|
589
568
|
event.type === 'toolcall_end';
|
|
590
|
-
if (isContent)
|
|
591
|
-
|
|
569
|
+
if (isContent) {
|
|
570
|
+
contentReceived = true;
|
|
571
|
+
recordTarget();
|
|
572
|
+
}
|
|
573
|
+
if (event.type === 'done' || event.type === 'error') {
|
|
574
|
+
terminalReceived = true;
|
|
575
|
+
recordTarget();
|
|
576
|
+
const cost = (
|
|
577
|
+
event.type === 'done' ? event.message : event.error
|
|
578
|
+
).usage.cost.total;
|
|
579
|
+
if (Number.isFinite(cost) && cost > 0)
|
|
580
|
+
state.accumulatedCost += cost;
|
|
581
|
+
}
|
|
582
|
+
if (contentReceived || terminalReceived) {
|
|
583
|
+
for (const pending of pendingEvents.splice(0))
|
|
584
|
+
stream.push(pending);
|
|
585
|
+
stream.push(event);
|
|
586
|
+
if ('partial' in event) partialMessage = event.partial;
|
|
587
|
+
} else {
|
|
588
|
+
pendingEvents.push(event);
|
|
589
|
+
}
|
|
590
|
+
if (terminalReceived) break;
|
|
592
591
|
}
|
|
592
|
+
if (!terminalReceived)
|
|
593
|
+
throw new Error(
|
|
594
|
+
'Provider stream ended without a terminal event.',
|
|
595
|
+
);
|
|
593
596
|
success = true;
|
|
594
|
-
if (i > 0) decision.isFallback = true;
|
|
595
597
|
break;
|
|
596
598
|
} catch (err) {
|
|
599
|
+
if (contentReceived || options?.signal?.aborted) throw err;
|
|
597
600
|
lastError = err;
|
|
598
601
|
}
|
|
599
602
|
}
|
|
@@ -610,28 +613,17 @@ export const registerRouterProvider = (
|
|
|
610
613
|
|
|
611
614
|
stream.end();
|
|
612
615
|
} catch (error) {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
stream.push({
|
|
621
|
-
type: 'done',
|
|
622
|
-
reason: 'stop',
|
|
623
|
-
message: createErrorMessage(model, ''),
|
|
624
|
-
});
|
|
625
|
-
} else {
|
|
626
|
-
stream.push({
|
|
627
|
-
type: 'error',
|
|
628
|
-
reason: 'error',
|
|
629
|
-
error: createErrorMessage(
|
|
630
|
-
model,
|
|
616
|
+
const reason = options?.signal?.aborted ? 'aborted' : 'error';
|
|
617
|
+
stream.push({
|
|
618
|
+
type: 'error',
|
|
619
|
+
reason,
|
|
620
|
+
error: {
|
|
621
|
+
...(partialMessage ?? createErrorMessage(model, '')),
|
|
622
|
+
errorMessage:
|
|
631
623
|
error instanceof Error ? error.message : String(error),
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
}
|
|
624
|
+
stopReason: reason,
|
|
625
|
+
},
|
|
626
|
+
});
|
|
635
627
|
stream.end();
|
|
636
628
|
} finally {
|
|
637
629
|
try {
|
package/extensions/routing.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
2
2
|
import type { Context, Message } from '@earendil-works/pi-ai';
|
|
3
|
-
import { streamSimple } from '@earendil-works/pi-ai/compat';
|
|
4
3
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
4
|
import { isRouterTier, parseCanonicalModelRef } from './config';
|
|
6
|
-
import {
|
|
7
|
-
hasUsableRequestAuth,
|
|
8
|
-
type RegistryWithProviderAuth,
|
|
9
|
-
resolveDelegatedModel,
|
|
10
|
-
} from './constants';
|
|
11
5
|
import type {
|
|
12
6
|
RouterPhase,
|
|
13
7
|
RouterProfile,
|
|
@@ -148,6 +142,7 @@ export const decideRouting = (
|
|
|
148
142
|
rules?: RoutingRule[],
|
|
149
143
|
isBudgetExceeded = false,
|
|
150
144
|
): RoutingDecision => {
|
|
145
|
+
if (previousDecision?.profile !== profileName) previousDecision = undefined;
|
|
151
146
|
const prompt = getLastUserText(context).toLowerCase();
|
|
152
147
|
const recentConversation = getRecentConversationText(context);
|
|
153
148
|
const toolResultCount = countToolResults(context);
|
|
@@ -372,7 +367,10 @@ export const decideRouting = (
|
|
|
372
367
|
}
|
|
373
368
|
|
|
374
369
|
// Resolve to nearest available tier if the selected tier is disabled
|
|
375
|
-
const resolvedTier =
|
|
370
|
+
const resolvedTier =
|
|
371
|
+
isBudgetForced && !profile.medium && profile.low
|
|
372
|
+
? 'low'
|
|
373
|
+
: resolveAvailableTier(profile, tier);
|
|
376
374
|
if (resolvedTier !== tier) {
|
|
377
375
|
reasoning = `Resolved from ${tier} to ${resolvedTier} tier (${tier} tier is not configured). Original: ${reasoning}`;
|
|
378
376
|
phase = phaseForTier(resolvedTier);
|
|
@@ -399,20 +397,13 @@ export const runClassifier = async (
|
|
|
399
397
|
context: Context,
|
|
400
398
|
currentPhase?: RouterPhase,
|
|
401
399
|
thinking?: ThinkingLevel,
|
|
400
|
+
signal?: AbortSignal,
|
|
402
401
|
): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
|
|
403
402
|
try {
|
|
404
403
|
const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
|
|
405
404
|
const model = modelRegistry.find(provider, modelId);
|
|
406
|
-
if (!model) return undefined;
|
|
407
|
-
|
|
408
|
-
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
409
|
-
if (!auth.ok || !hasUsableRequestAuth(auth)) return undefined;
|
|
410
|
-
const apiKey = auth.apiKey;
|
|
411
|
-
const headers = auth.headers;
|
|
412
|
-
const requestModel = await resolveDelegatedModel(
|
|
413
|
-
modelRegistry as unknown as RegistryWithProviderAuth,
|
|
414
|
-
model,
|
|
415
|
-
);
|
|
405
|
+
if (!model || provider === 'router') return undefined;
|
|
406
|
+
signal?.throwIfAborted();
|
|
416
407
|
|
|
417
408
|
const promptText = getLastUserText(context);
|
|
418
409
|
const historyText = getRecentConversationText(context, 4);
|
|
@@ -447,17 +438,24 @@ ${currentPhase === 'implementation' ? 'Consider that the conversation is current
|
|
|
447
438
|
const reasoningOption =
|
|
448
439
|
model.reasoning && thinking && thinking !== 'off' ? thinking : undefined;
|
|
449
440
|
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
441
|
+
const timeout = AbortSignal.timeout(10_000);
|
|
442
|
+
const stream = modelRegistry.streamSimple(model, classifierContext, {
|
|
443
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
444
|
+
maxTokens: 256,
|
|
453
445
|
...(reasoningOption ? { reasoning: reasoningOption } : {}),
|
|
454
446
|
});
|
|
455
447
|
let fullText = '';
|
|
448
|
+
let completed = false;
|
|
456
449
|
for await (const event of stream) {
|
|
457
|
-
if (event.type === '
|
|
458
|
-
|
|
450
|
+
if (event.type === 'error') return undefined;
|
|
451
|
+
if (event.type === 'text_delta') fullText += event.delta;
|
|
452
|
+
if (event.type === 'done') {
|
|
453
|
+
completed = true;
|
|
454
|
+
fullText = extractTextFromContent(event.message.content);
|
|
455
|
+
break;
|
|
459
456
|
}
|
|
460
457
|
}
|
|
458
|
+
if (!completed) return undefined;
|
|
461
459
|
|
|
462
460
|
const lines = fullText.trim().split('\n');
|
|
463
461
|
const tierLine = lines.find((l) => l.toLowerCase().startsWith('tier:'));
|
|
@@ -471,7 +469,7 @@ ${currentPhase === 'implementation' ? 'Consider that the conversation is current
|
|
|
471
469
|
return {
|
|
472
470
|
tier: tierValue,
|
|
473
471
|
reasoning: reasoningLine
|
|
474
|
-
? reasoningLine.
|
|
472
|
+
? reasoningLine.slice(reasoningLine.indexOf(':') + 1).trim()
|
|
475
473
|
: 'Classifier decision.',
|
|
476
474
|
};
|
|
477
475
|
}
|
package/extensions/state.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
import {
|
|
5
|
+
isObjectRecord,
|
|
6
|
+
isRouterTier,
|
|
7
|
+
isThinkingLevel,
|
|
8
|
+
parseCanonicalModelRef,
|
|
9
|
+
} from './config';
|
|
4
10
|
import type {
|
|
5
11
|
RouterLastProfileState,
|
|
6
12
|
RouterPersistedState,
|
|
@@ -11,13 +17,45 @@ import type {
|
|
|
11
17
|
|
|
12
18
|
const LAST_PROFILE_STATE_FILE = 'model-router-state.json';
|
|
13
19
|
|
|
14
|
-
const
|
|
15
|
-
|
|
20
|
+
const isPhase = (value: unknown) =>
|
|
21
|
+
value === 'planning' || value === 'implementation' || value === 'lightweight';
|
|
22
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
23
|
+
typeof value === 'number' && Number.isFinite(value);
|
|
24
|
+
const isModelRef = (value: unknown) => {
|
|
25
|
+
if (typeof value !== 'string') return false;
|
|
26
|
+
try {
|
|
27
|
+
parseCanonicalModelRef(value);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const isDecision = (value: unknown): value is RoutingDecision =>
|
|
34
|
+
isObjectRecord(value) &&
|
|
35
|
+
isRouterTier(value.tier) &&
|
|
36
|
+
isPhase(value.phase) &&
|
|
37
|
+
isThinkingLevel(value.thinking) &&
|
|
38
|
+
isFiniteNumber(value.timestamp) &&
|
|
39
|
+
[
|
|
40
|
+
'profile',
|
|
41
|
+
'targetProvider',
|
|
42
|
+
'targetModelId',
|
|
43
|
+
'targetLabel',
|
|
44
|
+
'reasoning',
|
|
45
|
+
].every((key) => typeof value[key] === 'string') &&
|
|
46
|
+
['isClassifier', 'isFallback', 'isBudgetForced', 'isRuleMatched'].every(
|
|
47
|
+
(key) => value[key] === undefined || typeof value[key] === 'boolean',
|
|
48
|
+
);
|
|
49
|
+
const isMap = (value: unknown, validate: (entry: unknown) => boolean) =>
|
|
50
|
+
isObjectRecord(value) &&
|
|
51
|
+
Object.entries(value).every(
|
|
52
|
+
([key, entry]) => key !== '__proto__' && validate(entry),
|
|
53
|
+
);
|
|
16
54
|
|
|
17
55
|
export const isRouterLastProfileState = (
|
|
18
56
|
value: unknown,
|
|
19
57
|
): value is RouterLastProfileState =>
|
|
20
|
-
|
|
58
|
+
isObjectRecord(value) &&
|
|
21
59
|
typeof value.selectedProfile === 'string' &&
|
|
22
60
|
value.selectedProfile.length > 0 &&
|
|
23
61
|
typeof value.timestamp === 'number';
|
|
@@ -58,16 +96,35 @@ export const saveLastRouterProfile = (
|
|
|
58
96
|
export const isRouterPersistedState = (
|
|
59
97
|
value: unknown,
|
|
60
98
|
): value is RouterPersistedState => {
|
|
61
|
-
if (
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
if (!isRecord(value)) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
99
|
+
if (!isObjectRecord(value)) return false;
|
|
67
100
|
return (
|
|
68
101
|
typeof value.enabled === 'boolean' &&
|
|
69
102
|
typeof value.selectedProfile === 'string' &&
|
|
70
|
-
|
|
103
|
+
isFiniteNumber(value.timestamp) &&
|
|
104
|
+
(value.pinTier === undefined || isRouterTier(value.pinTier)) &&
|
|
105
|
+
(value.pinByProfile === undefined ||
|
|
106
|
+
isMap(value.pinByProfile, isRouterTier)) &&
|
|
107
|
+
(value.thinkingByProfile === undefined ||
|
|
108
|
+
isMap(
|
|
109
|
+
value.thinkingByProfile,
|
|
110
|
+
(tiers) =>
|
|
111
|
+
isObjectRecord(tiers) &&
|
|
112
|
+
Object.entries(tiers).every(
|
|
113
|
+
([tier, level]) => isRouterTier(tier) && isThinkingLevel(level),
|
|
114
|
+
),
|
|
115
|
+
)) &&
|
|
116
|
+
(value.lastDecision === undefined || isDecision(value.lastDecision)) &&
|
|
117
|
+
(value.debugHistory === undefined ||
|
|
118
|
+
(Array.isArray(value.debugHistory) &&
|
|
119
|
+
value.debugHistory.every(isDecision))) &&
|
|
120
|
+
(value.lastPhase === undefined || isPhase(value.lastPhase)) &&
|
|
121
|
+
(value.lastNonRouterModel === undefined ||
|
|
122
|
+
isModelRef(value.lastNonRouterModel)) &&
|
|
123
|
+
(value.accumulatedCost === undefined ||
|
|
124
|
+
(isFiniteNumber(value.accumulatedCost) && value.accumulatedCost >= 0)) &&
|
|
125
|
+
['debugEnabled', 'widgetEnabled'].every(
|
|
126
|
+
(key) => value[key] === undefined || typeof value[key] === 'boolean',
|
|
127
|
+
)
|
|
71
128
|
);
|
|
72
129
|
};
|
|
73
130
|
|
|
@@ -83,7 +140,7 @@ export const buildPersistedState = (
|
|
|
83
140
|
lastNonRouterModel: string | undefined,
|
|
84
141
|
accumulatedCost: number,
|
|
85
142
|
): RouterPersistedState => {
|
|
86
|
-
return {
|
|
143
|
+
return structuredClone({
|
|
87
144
|
enabled: routerEnabled,
|
|
88
145
|
selectedProfile: selectedProfile ?? '',
|
|
89
146
|
pinTier: selectedProfile ? pinnedTierByProfile[selectedProfile] : undefined,
|
|
@@ -97,5 +154,5 @@ export const buildPersistedState = (
|
|
|
97
154
|
lastNonRouterModel,
|
|
98
155
|
accumulatedCost,
|
|
99
156
|
timestamp: Date.now(),
|
|
100
|
-
};
|
|
157
|
+
});
|
|
101
158
|
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexeiled/pi-model-router",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"extensions",
|
|
7
|
-
"!extensions
|
|
7
|
+
"!extensions/**/*.test.ts",
|
|
8
|
+
"!extensions/test",
|
|
8
9
|
"LICENSE",
|
|
9
10
|
"model-router.example.json",
|
|
10
11
|
"README.md",
|