@kdejaeger/pi-model-router 0.4.2 → 0.4.5
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/README.md +2 -2
- package/extensions/commands.ts +19 -74
- package/extensions/config.ts +104 -105
- package/extensions/index.ts +55 -67
- package/extensions/provider.ts +135 -41
- package/extensions/routing.ts +24 -11
- package/extensions/state.ts +3 -13
- package/extensions/ui.ts +2 -7
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -23,8 +23,8 @@ This extension (forked from [yeliu84/pi-model-router](https://github.com/yeliu84
|
|
|
23
23
|
|
|
24
24
|
The pi-model-router registers itself as a **custom logical provider** (`router`) via `pi.registerProvider`. Each profile becomes a stable model (e.g., `router/cheap`). The model shown in your footer on the left stays fixed, while the underlying LLM changes per turn based on task complexity.
|
|
25
25
|
|
|
26
|
-

|
|
27
|
-

|
|
26
|
+

|
|
27
|
+

|
|
28
28
|
|
|
29
29
|
For the full decision pipeline, see [How Routing Works](#how-routing-works).
|
|
30
30
|
|
package/extensions/commands.ts
CHANGED
|
@@ -1,22 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionContext,
|
|
4
|
-
} from '@earendil-works/pi-coding-agent';
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
2
|
import type { AutocompleteItem } from '@earendil-works/pi-tui';
|
|
6
|
-
import type {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
RoutingDecision,
|
|
10
|
-
} from './types';
|
|
11
|
-
import {
|
|
12
|
-
profileNames,
|
|
13
|
-
ROUTER_PIN_VALUES,
|
|
14
|
-
resolveModelFromRef,
|
|
15
|
-
isPinValue,
|
|
16
|
-
} from './config';
|
|
17
|
-
import {
|
|
18
|
-
formatPinSummary,
|
|
19
|
-
} from './ui';
|
|
3
|
+
import type { RouterConfig, RouterPinByProfile, RoutingDecision } from './types';
|
|
4
|
+
import { profileNames, ROUTER_PIN_VALUES, resolveModelFromRef, isPinValue } from './config';
|
|
5
|
+
import { formatPinSummary } from './ui';
|
|
20
6
|
|
|
21
7
|
export const registerCommands = (
|
|
22
8
|
pi: ExtensionAPI,
|
|
@@ -33,16 +19,9 @@ export const registerCommands = (
|
|
|
33
19
|
actions: {
|
|
34
20
|
persistState: () => void;
|
|
35
21
|
updateStatus: (ctx: ExtensionContext) => void;
|
|
36
|
-
reloadConfig: (
|
|
37
|
-
ctx?: ExtensionContext,
|
|
38
|
-
options?: { preserveDebug?: boolean },
|
|
39
|
-
) => void;
|
|
22
|
+
reloadConfig: (ctx?: ExtensionContext, options?: { preserveDebug?: boolean }) => void;
|
|
40
23
|
ensureValidActiveRouterProfile: (ctx: ExtensionContext) => Promise<void>;
|
|
41
|
-
switchToRouterProfile: (
|
|
42
|
-
profileName: string,
|
|
43
|
-
ctx: ExtensionContext,
|
|
44
|
-
strict?: boolean,
|
|
45
|
-
) => Promise<boolean>;
|
|
24
|
+
switchToRouterProfile: (profileName: string, ctx: ExtensionContext, strict?: boolean) => Promise<boolean>;
|
|
46
25
|
},
|
|
47
26
|
) => {
|
|
48
27
|
const SUBCOMMAND_DETAILS = [
|
|
@@ -56,12 +35,8 @@ export const registerCommands = (
|
|
|
56
35
|
{ name: 'help', desc: 'Show usage help for subcommands' },
|
|
57
36
|
];
|
|
58
37
|
|
|
59
|
-
const getSubcommandCompletions = (
|
|
60
|
-
prefix
|
|
61
|
-
): AutocompleteItem[] | null => {
|
|
62
|
-
const items = SUBCOMMAND_DETAILS.filter((s) =>
|
|
63
|
-
s.name.startsWith(prefix),
|
|
64
|
-
).map((s) => ({
|
|
38
|
+
const getSubcommandCompletions = (prefix: string): AutocompleteItem[] | null => {
|
|
39
|
+
const items = SUBCOMMAND_DETAILS.filter((s) => s.name.startsWith(prefix)).map((s) => ({
|
|
65
40
|
value: s.name,
|
|
66
41
|
label: s.name,
|
|
67
42
|
description: s.desc,
|
|
@@ -73,9 +48,7 @@ export const registerCommands = (
|
|
|
73
48
|
// pin [profile] <tier|clear>
|
|
74
49
|
if (args.length <= 1) {
|
|
75
50
|
const token = args[0] ?? '';
|
|
76
|
-
const pinItems = ROUTER_PIN_VALUES.filter((value) =>
|
|
77
|
-
value.startsWith(token),
|
|
78
|
-
).map((value) => ({ value, label: value }));
|
|
51
|
+
const pinItems = ROUTER_PIN_VALUES.filter((value) => value.startsWith(token)).map((value) => ({ value, label: value }));
|
|
79
52
|
const profileItems = profileNames(state.currentConfig)
|
|
80
53
|
.filter((name) => name.startsWith(token))
|
|
81
54
|
.map((name) => ({ value: name, label: `router/${name}` }));
|
|
@@ -86,9 +59,7 @@ export const registerCommands = (
|
|
|
86
59
|
const profileToken = args[0];
|
|
87
60
|
if (!state.currentConfig.profiles[profileToken]) return null;
|
|
88
61
|
const pinPrefix = args[1] ?? '';
|
|
89
|
-
const items = ROUTER_PIN_VALUES.filter((value) =>
|
|
90
|
-
value.startsWith(pinPrefix),
|
|
91
|
-
).map((value) => ({
|
|
62
|
+
const items = ROUTER_PIN_VALUES.filter((value) => value.startsWith(pinPrefix)).map((value) => ({
|
|
92
63
|
value: `${profileToken} ${value}`,
|
|
93
64
|
label: `${profileToken} ${value}`,
|
|
94
65
|
}));
|
|
@@ -107,9 +78,7 @@ export const registerCommands = (
|
|
|
107
78
|
ctx.ui.notify('Usage: /router status (no arguments)', 'error');
|
|
108
79
|
return;
|
|
109
80
|
}
|
|
110
|
-
const profilePin = state.selectedProfile
|
|
111
|
-
? state.pinnedTierByProfile[state.selectedProfile] ?? 'none'
|
|
112
|
-
: 'none';
|
|
81
|
+
const profilePin = state.selectedProfile ? (state.pinnedTierByProfile[state.selectedProfile] ?? 'none') : 'none';
|
|
113
82
|
const lines = [
|
|
114
83
|
`Router enabled: ${state.routerEnabled ? 'yes' : 'off'}`,
|
|
115
84
|
`Selected profile: ${state.selectedProfile ?? 'none'}`,
|
|
@@ -193,10 +162,7 @@ export const registerCommands = (
|
|
|
193
162
|
}
|
|
194
163
|
|
|
195
164
|
if (!isPinValue(pinValue)) {
|
|
196
|
-
ctx.ui.notify(
|
|
197
|
-
`Invalid router pin: ${pinValue}. Use high, medium, low, or clear`,
|
|
198
|
-
'error',
|
|
199
|
-
);
|
|
165
|
+
ctx.ui.notify(`Invalid router pin: ${pinValue}. Use high, medium, low, or clear`, 'error');
|
|
200
166
|
return;
|
|
201
167
|
}
|
|
202
168
|
|
|
@@ -235,10 +201,7 @@ export const registerCommands = (
|
|
|
235
201
|
}
|
|
236
202
|
const targetModel = resolveModelFromRef(state.lastNonRouterModel, ctx.modelRegistry);
|
|
237
203
|
if (!targetModel) {
|
|
238
|
-
ctx.ui.notify(
|
|
239
|
-
`Recorded non-router model is unavailable: ${state.lastNonRouterModel}`,
|
|
240
|
-
'error',
|
|
241
|
-
);
|
|
204
|
+
ctx.ui.notify(`Recorded non-router model is unavailable: ${state.lastNonRouterModel}`, 'error');
|
|
242
205
|
return;
|
|
243
206
|
}
|
|
244
207
|
const success = await pi.setModel(targetModel);
|
|
@@ -250,10 +213,7 @@ export const registerCommands = (
|
|
|
250
213
|
actions.persistState();
|
|
251
214
|
pi.setThinkingLevel('off');
|
|
252
215
|
actions.updateStatus(ctx);
|
|
253
|
-
ctx.ui.notify(
|
|
254
|
-
`Router disabled. Restored ${state.lastNonRouterModel}`,
|
|
255
|
-
'info',
|
|
256
|
-
);
|
|
216
|
+
ctx.ui.notify(`Router disabled. Restored ${state.lastNonRouterModel}`, 'info');
|
|
257
217
|
};
|
|
258
218
|
|
|
259
219
|
const handleDebug = async (args: string[], ctx: ExtensionContext) => {
|
|
@@ -269,10 +229,7 @@ export const registerCommands = (
|
|
|
269
229
|
}
|
|
270
230
|
actions.persistState();
|
|
271
231
|
actions.updateStatus(ctx);
|
|
272
|
-
ctx.ui.notify(
|
|
273
|
-
`Router debug ${state.debugEnabled ? 'enabled' : 'disabled'}.`,
|
|
274
|
-
'info',
|
|
275
|
-
);
|
|
232
|
+
ctx.ui.notify(`Router debug ${state.debugEnabled ? 'enabled' : 'disabled'}.`, 'info');
|
|
276
233
|
};
|
|
277
234
|
|
|
278
235
|
const handleReload = async (args: string[], ctx: ExtensionContext) => {
|
|
@@ -288,10 +245,7 @@ export const registerCommands = (
|
|
|
288
245
|
ctx.ui.notify(`Router reload warnings:\n${state.lastConfigWarnings.join('\n')}`, 'warning');
|
|
289
246
|
}
|
|
290
247
|
|
|
291
|
-
ctx.ui.notify(
|
|
292
|
-
`Router config reloaded. Profiles: ${profileNames(state.currentConfig).join(', ')}`,
|
|
293
|
-
'info',
|
|
294
|
-
);
|
|
248
|
+
ctx.ui.notify(`Router config reloaded. Profiles: ${profileNames(state.currentConfig).join(', ')}`, 'info');
|
|
295
249
|
};
|
|
296
250
|
|
|
297
251
|
pi.registerCommand('router', {
|
|
@@ -412,22 +366,13 @@ export const registerCommands = (
|
|
|
412
366
|
// Check if subcommand is actually a profile name (backwards compatible-ish with /router-on)
|
|
413
367
|
if (state.currentConfig.profiles[subcommand]) {
|
|
414
368
|
if (subArgs.length > 0) {
|
|
415
|
-
ctx.ui.notify(
|
|
416
|
-
`Usage: /router ${subcommand} (no extra arguments allowed)`,
|
|
417
|
-
'error',
|
|
418
|
-
);
|
|
369
|
+
ctx.ui.notify(`Usage: /router ${subcommand} (no extra arguments allowed)`, 'error');
|
|
419
370
|
return;
|
|
420
371
|
}
|
|
421
372
|
await actions.switchToRouterProfile(subcommand, ctx);
|
|
422
|
-
ctx.ui.notify(
|
|
423
|
-
`Router enabled with profile: ${state.selectedProfile}`,
|
|
424
|
-
'info',
|
|
425
|
-
);
|
|
373
|
+
ctx.ui.notify(`Router enabled with profile: ${state.selectedProfile}`, 'info');
|
|
426
374
|
} else {
|
|
427
|
-
ctx.ui.notify(
|
|
428
|
-
`Unknown router subcommand: ${subcommand}. Try /router help`,
|
|
429
|
-
'error',
|
|
430
|
-
);
|
|
375
|
+
ctx.ui.notify(`Unknown router subcommand: ${subcommand}. Try /router help`, 'error');
|
|
431
376
|
}
|
|
432
377
|
} else {
|
|
433
378
|
await handleStatus(subArgs, ctx);
|
package/extensions/config.ts
CHANGED
|
@@ -1,26 +1,23 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type Api,
|
|
5
|
+
type AssistantMessageEventStream,
|
|
6
|
+
type Context,
|
|
7
|
+
type Model,
|
|
8
|
+
type SimpleStreamOptions,
|
|
9
|
+
} from '@earendil-works/pi-ai';
|
|
10
|
+
import { streamSimple } from '@earendil-works/pi-ai/compat';
|
|
4
11
|
import { getAgentDir, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
12
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
6
|
-
import type {
|
|
7
|
-
RouterConfig,
|
|
8
|
-
RouterProfile,
|
|
9
|
-
RoutedTierConfig,
|
|
10
|
-
ConfigLoadResult,
|
|
11
|
-
ParsedConfigFile,
|
|
12
|
-
RouterTier,
|
|
13
|
-
} from './types';
|
|
13
|
+
import type { RouterConfig, RouterProfile, RoutedTierConfig, ConfigLoadResult, ParsedConfigFile, RouterTier } from './types';
|
|
14
14
|
|
|
15
15
|
export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
|
|
16
16
|
|
|
17
|
-
const THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
17
|
+
const THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
18
18
|
export const ROUTER_PIN_VALUES = ['clear', 'high', 'medium', 'low'] as const;
|
|
19
19
|
|
|
20
|
-
const isObjectRecord = (
|
|
21
|
-
value: unknown,
|
|
22
|
-
): value is Record<string, unknown> =>
|
|
23
|
-
typeof value === 'object' && value !== null;
|
|
20
|
+
const isObjectRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
|
24
21
|
|
|
25
22
|
const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
|
|
26
23
|
typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
|
|
@@ -32,7 +29,12 @@ const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
|
|
|
32
29
|
export const isPinValue = (value: string): value is (typeof ROUTER_PIN_VALUES)[number] =>
|
|
33
30
|
(ROUTER_PIN_VALUES as readonly string[]).includes(value);
|
|
34
31
|
|
|
35
|
-
const validateNonNegativeInt = (
|
|
32
|
+
const validateNonNegativeInt = (
|
|
33
|
+
val: unknown,
|
|
34
|
+
label: string,
|
|
35
|
+
fallback: number | undefined,
|
|
36
|
+
warnings: string[],
|
|
37
|
+
): number | undefined => {
|
|
36
38
|
if (val === undefined || val === null) return fallback;
|
|
37
39
|
if (typeof val !== 'number' || !Number.isInteger(val) || val < 0) {
|
|
38
40
|
warnings.push(`Invalid ${label} (${JSON.stringify(val)}). Must be a non-negative integer.`);
|
|
@@ -58,26 +60,18 @@ const parseConfigFile = (path: string): ParsedConfigFile => {
|
|
|
58
60
|
} catch (error) {
|
|
59
61
|
return {
|
|
60
62
|
config: {},
|
|
61
|
-
warnings: [
|
|
62
|
-
`Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
63
|
-
],
|
|
63
|
+
warnings: [`Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`],
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
const mergeTier = (
|
|
69
|
-
existing?: RoutedTierConfig,
|
|
70
|
-
next?: Partial<RoutedTierConfig>,
|
|
71
|
-
): RoutedTierConfig | undefined => {
|
|
68
|
+
const mergeTier = (existing?: RoutedTierConfig, next?: Partial<RoutedTierConfig>): RoutedTierConfig | undefined => {
|
|
72
69
|
if (!next) return existing;
|
|
73
70
|
if (!existing) return next?.model ? (next as RoutedTierConfig) : undefined;
|
|
74
71
|
return { ...existing, ...next };
|
|
75
72
|
};
|
|
76
73
|
|
|
77
|
-
const mergeConfig = (
|
|
78
|
-
base: RouterConfig,
|
|
79
|
-
override: Partial<RouterConfig>,
|
|
80
|
-
): RouterConfig => {
|
|
74
|
+
const mergeConfig = (base: RouterConfig, override: Partial<RouterConfig>): RouterConfig => {
|
|
81
75
|
const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
|
|
82
76
|
for (const [name, profile] of Object.entries(override.profiles ?? {})) {
|
|
83
77
|
if (!isObjectRecord(profile)) continue;
|
|
@@ -92,37 +86,25 @@ const mergeConfig = (
|
|
|
92
86
|
return {
|
|
93
87
|
debug: override.debug ?? base.debug,
|
|
94
88
|
classifierModels: override.classifierModels ?? base.classifierModels,
|
|
95
|
-
classifierModelThinking:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
classifierInterval:
|
|
102
|
-
override.classifierInterval ?? base.classifierInterval,
|
|
103
|
-
defaultContextThresholdPercent:
|
|
104
|
-
override.defaultContextThresholdPercent ?? base.defaultContextThresholdPercent,
|
|
105
|
-
contextThresholdPercentOverrides:
|
|
106
|
-
override.contextThresholdPercentOverrides ?? base.contextThresholdPercentOverrides,
|
|
89
|
+
classifierModelThinking: override.classifierModelThinking ?? base.classifierModelThinking,
|
|
90
|
+
classifierRunOnceAfterToolCount: override.classifierRunOnceAfterToolCount ?? base.classifierRunOnceAfterToolCount,
|
|
91
|
+
classifierRunAfterToolFailures: override.classifierRunAfterToolFailures ?? base.classifierRunAfterToolFailures,
|
|
92
|
+
classifierInterval: override.classifierInterval ?? base.classifierInterval,
|
|
93
|
+
defaultContextThresholdPercent: override.defaultContextThresholdPercent ?? base.defaultContextThresholdPercent,
|
|
94
|
+
contextThresholdPercentOverrides: override.contextThresholdPercentOverrides ?? base.contextThresholdPercentOverrides,
|
|
107
95
|
profiles: mergedProfiles,
|
|
108
96
|
};
|
|
109
97
|
};
|
|
110
98
|
|
|
111
|
-
export const parseCanonicalModelRef = (
|
|
112
|
-
value: string,
|
|
113
|
-
): { provider: string; modelId: string } => {
|
|
99
|
+
export const parseCanonicalModelRef = (value: string): { provider: string; modelId: string } => {
|
|
114
100
|
const slashIndex = value.indexOf('/');
|
|
115
101
|
if (slashIndex === -1) {
|
|
116
|
-
throw new Error(
|
|
117
|
-
`Invalid model reference "${value}". Expected "provider/model".`,
|
|
118
|
-
);
|
|
102
|
+
throw new Error(`Invalid model reference "${value}". Expected "provider/model".`);
|
|
119
103
|
}
|
|
120
104
|
const provider = value.slice(0, slashIndex).trim();
|
|
121
105
|
const modelId = value.slice(slashIndex + 1).trim();
|
|
122
106
|
if (!provider || !modelId) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
`Invalid model reference "${value}". Expected "provider/model".`,
|
|
125
|
-
);
|
|
107
|
+
throw new Error(`Invalid model reference "${value}". Expected "provider/model".`);
|
|
126
108
|
}
|
|
127
109
|
return { provider, modelId };
|
|
128
110
|
};
|
|
@@ -155,9 +137,7 @@ const normalizeTierConfig = (
|
|
|
155
137
|
|
|
156
138
|
const model = typeof value.model === 'string' ? value.model.trim() : '';
|
|
157
139
|
if (!model) {
|
|
158
|
-
warnings.push(
|
|
159
|
-
`Profile "${profileName}" ${tier} tier is missing a model. Tier disabled.`,
|
|
160
|
-
);
|
|
140
|
+
warnings.push(`Profile "${profileName}" ${tier} tier is missing a model. Tier disabled.`);
|
|
161
141
|
return undefined;
|
|
162
142
|
}
|
|
163
143
|
|
|
@@ -170,13 +150,9 @@ const normalizeTierConfig = (
|
|
|
170
150
|
return undefined;
|
|
171
151
|
}
|
|
172
152
|
|
|
173
|
-
const thinking = isThinkingLevel(value.thinking)
|
|
174
|
-
? value.thinking
|
|
175
|
-
: 'medium';
|
|
153
|
+
const thinking = isThinkingLevel(value.thinking) ? value.thinking : 'medium';
|
|
176
154
|
if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
|
|
177
|
-
warnings.push(
|
|
178
|
-
`Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`,
|
|
179
|
-
);
|
|
155
|
+
warnings.push(`Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`);
|
|
180
156
|
}
|
|
181
157
|
|
|
182
158
|
let fallbacks: string[] | undefined;
|
|
@@ -213,29 +189,12 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
213
189
|
if (trimmedName !== name) {
|
|
214
190
|
warnings.push(`Profile name "${name}" has leading/trailing whitespace. Using "${trimmedName}".`);
|
|
215
191
|
}
|
|
216
|
-
const high = normalizeTierConfig(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'high',
|
|
220
|
-
warnings,
|
|
221
|
-
);
|
|
222
|
-
const medium = normalizeTierConfig(
|
|
223
|
-
profile?.medium,
|
|
224
|
-
trimmedName,
|
|
225
|
-
'medium',
|
|
226
|
-
warnings,
|
|
227
|
-
);
|
|
228
|
-
const low = normalizeTierConfig(
|
|
229
|
-
profile?.low,
|
|
230
|
-
trimmedName,
|
|
231
|
-
'low',
|
|
232
|
-
warnings,
|
|
233
|
-
);
|
|
192
|
+
const high = normalizeTierConfig(profile?.high, trimmedName, 'high', warnings);
|
|
193
|
+
const medium = normalizeTierConfig(profile?.medium, trimmedName, 'medium', warnings);
|
|
194
|
+
const low = normalizeTierConfig(profile?.low, trimmedName, 'low', warnings);
|
|
234
195
|
|
|
235
196
|
if (!high && !medium && !low) {
|
|
236
|
-
warnings.push(
|
|
237
|
-
`Profile "${trimmedName}" has no valid tiers. Skipped.`,
|
|
238
|
-
);
|
|
197
|
+
warnings.push(`Profile "${trimmedName}" has no valid tiers. Skipped.`);
|
|
239
198
|
continue;
|
|
240
199
|
}
|
|
241
200
|
if (!high) {
|
|
@@ -271,9 +230,7 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
271
230
|
`defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) is not a positive number. Falling back to 90.`,
|
|
272
231
|
);
|
|
273
232
|
} else if (raw.defaultContextThresholdPercent > 100) {
|
|
274
|
-
warnings.push(
|
|
275
|
-
`defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) exceeds 100. Falling back to 90.`,
|
|
276
|
-
);
|
|
233
|
+
warnings.push(`defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) exceeds 100. Falling back to 90.`);
|
|
277
234
|
} else {
|
|
278
235
|
defaultContextThresholdPercent = raw.defaultContextThresholdPercent;
|
|
279
236
|
}
|
|
@@ -287,11 +244,15 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
287
244
|
try {
|
|
288
245
|
parseCanonicalModelRef(trimmed);
|
|
289
246
|
} catch (error) {
|
|
290
|
-
warnings.push(
|
|
247
|
+
warnings.push(
|
|
248
|
+
`Ignored contextThresholdPercentOverride "${key}": invalid model reference — ${error instanceof Error ? error.message : String(error)}`,
|
|
249
|
+
);
|
|
291
250
|
return [];
|
|
292
251
|
}
|
|
293
252
|
if (typeof val !== 'number' || val <= 0) {
|
|
294
|
-
warnings.push(
|
|
253
|
+
warnings.push(
|
|
254
|
+
`Ignored contextThresholdPercentOverride "${key}" (${JSON.stringify(val)}): expected a positive number.`,
|
|
255
|
+
);
|
|
295
256
|
return [];
|
|
296
257
|
}
|
|
297
258
|
return [[trimmed, val] as [string, number]];
|
|
@@ -310,9 +271,7 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
310
271
|
parseCanonicalModelRef(trimmedCM);
|
|
311
272
|
classifierModels.push(trimmedCM);
|
|
312
273
|
} catch (error) {
|
|
313
|
-
warnings.push(
|
|
314
|
-
`Invalid classifierModels entry "${rawCM}": ${error instanceof Error ? error.message : String(error)}`,
|
|
315
|
-
);
|
|
274
|
+
warnings.push(`Invalid classifierModels entry "${rawCM}": ${error instanceof Error ? error.message : String(error)}`);
|
|
316
275
|
}
|
|
317
276
|
} else {
|
|
318
277
|
warnings.push(`Ignored non-string classifierModels entry: ${JSON.stringify(rawCM)}`);
|
|
@@ -328,8 +287,18 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
328
287
|
warnings.push(`Invalid classifierModelThinking value "${raw.classifierModelThinking}". Falling back to "off".`);
|
|
329
288
|
}
|
|
330
289
|
|
|
331
|
-
const classifierRunOnceAfterToolCount = validateNonNegativeInt(
|
|
332
|
-
|
|
290
|
+
const classifierRunOnceAfterToolCount = validateNonNegativeInt(
|
|
291
|
+
raw.classifierRunOnceAfterToolCount,
|
|
292
|
+
'classifierRunOnceAfterToolCount',
|
|
293
|
+
3,
|
|
294
|
+
warnings,
|
|
295
|
+
);
|
|
296
|
+
const classifierRunAfterToolFailures = validateNonNegativeInt(
|
|
297
|
+
raw.classifierRunAfterToolFailures,
|
|
298
|
+
'classifierRunAfterToolFailures',
|
|
299
|
+
2,
|
|
300
|
+
warnings,
|
|
301
|
+
);
|
|
333
302
|
const classifierInterval = validateNonNegativeInt(raw.classifierInterval, 'classifierInterval', 10, warnings);
|
|
334
303
|
|
|
335
304
|
return {
|
|
@@ -354,18 +323,11 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
|
|
|
354
323
|
const globalResult = parseConfigFile(globalPath);
|
|
355
324
|
const projectResult = parseConfigFile(projectPath);
|
|
356
325
|
const baseConfig: RouterConfig = { profiles: {} };
|
|
357
|
-
const merged = mergeConfig(
|
|
358
|
-
mergeConfig(baseConfig, globalResult.config),
|
|
359
|
-
projectResult.config,
|
|
360
|
-
);
|
|
326
|
+
const merged = mergeConfig(mergeConfig(baseConfig, globalResult.config), projectResult.config);
|
|
361
327
|
const normalized = normalizeConfig(merged);
|
|
362
328
|
return {
|
|
363
329
|
config: normalized.config,
|
|
364
|
-
warnings: [
|
|
365
|
-
...globalResult.warnings,
|
|
366
|
-
...projectResult.warnings,
|
|
367
|
-
...normalized.warnings,
|
|
368
|
-
],
|
|
330
|
+
warnings: [...globalResult.warnings, ...projectResult.warnings, ...normalized.warnings],
|
|
369
331
|
};
|
|
370
332
|
};
|
|
371
333
|
|
|
@@ -386,20 +348,57 @@ export const OPENROUTER_ATTR_HEADERS: Readonly<Record<string, string>> = {
|
|
|
386
348
|
/** Create an onPayload handler that injects session_id for OpenRouter session tracking. */
|
|
387
349
|
export const createOpenRouterOnPayload = (
|
|
388
350
|
sessionProvider?: { getSessionId(): string; getSessionName(): string | undefined },
|
|
389
|
-
origOnPayload?: (
|
|
390
|
-
): ((
|
|
351
|
+
origOnPayload?: (payload: unknown, model: Model<Api>) => unknown | Promise<unknown>,
|
|
352
|
+
): ((payload: unknown, model: Model<Api>) => Promise<unknown>) | undefined => {
|
|
391
353
|
const rawId = sessionProvider?.getSessionId();
|
|
392
354
|
const name = sessionProvider?.getSessionName();
|
|
393
355
|
const sessionId = name && rawId ? `${name.replace(/\s+/g, '-')}-${rawId.slice(0, 8)}` : rawId;
|
|
394
356
|
if (!sessionId) return undefined;
|
|
395
|
-
return async (p:
|
|
396
|
-
const payload = origOnPayload ? await origOnPayload(p, m) : p;
|
|
397
|
-
return { ...payload, session_id: sessionId };
|
|
357
|
+
return async (p: unknown, m: Model<Api>) => {
|
|
358
|
+
const payload = origOnPayload ? ((await origOnPayload(p, m)) ?? p) : p;
|
|
359
|
+
return typeof payload === 'object' && payload !== null ? { ...payload, session_id: sessionId } : payload;
|
|
398
360
|
};
|
|
399
361
|
};
|
|
400
362
|
|
|
401
|
-
export const resolveProfileName = (
|
|
402
|
-
config: RouterConfig,
|
|
403
|
-
requested?: string,
|
|
404
|
-
): string | undefined =>
|
|
363
|
+
export const resolveProfileName = (config: RouterConfig, requested?: string): string | undefined =>
|
|
405
364
|
requested && config.profiles[requested] ? requested : undefined;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Duck-typing interface for forward-compatible dispatch with Pi ModelRegistry
|
|
368
|
+
* runtimes that register custom provider stream handlers.
|
|
369
|
+
*/
|
|
370
|
+
interface RegistryWithProviderDispatch {
|
|
371
|
+
getRegisteredProviderConfig?(providerName: string):
|
|
372
|
+
| {
|
|
373
|
+
api?: Api;
|
|
374
|
+
streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
|
|
375
|
+
}
|
|
376
|
+
| undefined;
|
|
377
|
+
getRegisteredNativeProvider?(providerName: string):
|
|
378
|
+
| {
|
|
379
|
+
streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
|
|
380
|
+
}
|
|
381
|
+
| undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Dispatch streaming to a registered custom provider if available,
|
|
386
|
+
* falling back to the global compatibility streamSimple.
|
|
387
|
+
*/
|
|
388
|
+
export const dispatchStream = (
|
|
389
|
+
model: Model<Api>,
|
|
390
|
+
context: Context,
|
|
391
|
+
options: SimpleStreamOptions,
|
|
392
|
+
modelRegistry?: ExtensionContext['modelRegistry'],
|
|
393
|
+
): AssistantMessageEventStream => {
|
|
394
|
+
const registry = modelRegistry as unknown as RegistryWithProviderDispatch | undefined;
|
|
395
|
+
const providerConfig = registry?.getRegisteredProviderConfig?.(model.provider);
|
|
396
|
+
if (providerConfig?.streamSimple && (!providerConfig.api || providerConfig.api === model.api)) {
|
|
397
|
+
return providerConfig.streamSimple(model, context, options);
|
|
398
|
+
}
|
|
399
|
+
const nativeProvider = registry?.getRegisteredNativeProvider?.(model.provider);
|
|
400
|
+
if (nativeProvider?.streamSimple) {
|
|
401
|
+
return nativeProvider.streamSimple(model, context, options);
|
|
402
|
+
}
|
|
403
|
+
return streamSimple(model, context, options);
|
|
404
|
+
};
|
package/extensions/index.ts
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import {
|
|
6
|
-
type RouterConfig,
|
|
7
|
-
type RoutingDecision,
|
|
8
|
-
type RouterPinByProfile,
|
|
9
|
-
type CustomSessionEntry,
|
|
10
|
-
} from './types';
|
|
11
|
-
import {
|
|
12
|
-
loadRouterConfig,
|
|
13
|
-
profileNames,
|
|
14
|
-
resolveProfileName,
|
|
15
|
-
} from './config';
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { type RouterConfig, type RoutingDecision, type RouterPinByProfile, type CustomSessionEntry } from './types';
|
|
3
|
+
import { loadRouterConfig, profileNames, resolveProfileName } from './config';
|
|
16
4
|
import { isRouterPersistedState, buildPersistedState } from './state';
|
|
17
5
|
import { updateStatus } from './ui';
|
|
18
6
|
import { registerCommands } from './commands';
|
|
@@ -35,12 +23,13 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
35
23
|
let isInitialized = false;
|
|
36
24
|
let isRouterDelegating = false;
|
|
37
25
|
|
|
38
|
-
const setModelInternally = async (
|
|
39
|
-
model: NonNullable<ExtensionContext['model']>,
|
|
40
|
-
) => {
|
|
26
|
+
const setModelInternally = async (model: NonNullable<ExtensionContext['model']>) => {
|
|
41
27
|
isRouterDelegating = true;
|
|
42
28
|
try {
|
|
43
29
|
return await pi.setModel(model);
|
|
30
|
+
} catch {
|
|
31
|
+
// Extension context may be stale after session teardown.
|
|
32
|
+
return false;
|
|
44
33
|
} finally {
|
|
45
34
|
isRouterDelegating = false;
|
|
46
35
|
}
|
|
@@ -58,31 +47,24 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
58
47
|
const snapshot = JSON.stringify({
|
|
59
48
|
...state,
|
|
60
49
|
timestamp: 0,
|
|
61
|
-
lastDecision: state.lastDecision
|
|
62
|
-
? { ...state.lastDecision, timestamp: 0 }
|
|
63
|
-
: undefined,
|
|
50
|
+
lastDecision: state.lastDecision ? { ...state.lastDecision, timestamp: 0 } : undefined,
|
|
64
51
|
});
|
|
65
52
|
if (snapshot === lastPersistedSnapshot) {
|
|
66
53
|
return;
|
|
67
54
|
}
|
|
68
|
-
|
|
55
|
+
try {
|
|
56
|
+
pi.appendEntry('router-state', state);
|
|
57
|
+
} catch {
|
|
58
|
+
// Defensive fallback: session_shutdown or teardown may have invalidated context
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
69
61
|
lastPersistedSnapshot = snapshot;
|
|
70
62
|
};
|
|
71
63
|
|
|
72
64
|
const actions = {
|
|
73
65
|
persistState,
|
|
74
|
-
updateStatus: (ctx: ExtensionContext) =>
|
|
75
|
-
|
|
76
|
-
ctx,
|
|
77
|
-
routerEnabled,
|
|
78
|
-
selectedProfile,
|
|
79
|
-
pinnedTierByProfile,
|
|
80
|
-
lastDecision,
|
|
81
|
-
),
|
|
82
|
-
reloadConfig: (
|
|
83
|
-
ctx?: ExtensionContext,
|
|
84
|
-
options?: { preserveDebug?: boolean },
|
|
85
|
-
) => {
|
|
66
|
+
updateStatus: (ctx: ExtensionContext) => updateStatus(ctx, routerEnabled, selectedProfile, pinnedTierByProfile, lastDecision),
|
|
67
|
+
reloadConfig: (ctx?: ExtensionContext, options?: { preserveDebug?: boolean }) => {
|
|
86
68
|
const loaded = loadRouterConfig(currentCwd);
|
|
87
69
|
currentConfig = loaded.config;
|
|
88
70
|
lastConfigWarnings = loaded.warnings;
|
|
@@ -114,11 +96,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
114
96
|
routerEnabled = false;
|
|
115
97
|
selectedProfile = undefined;
|
|
116
98
|
},
|
|
117
|
-
switchToRouterProfile: async (
|
|
118
|
-
profileName: string,
|
|
119
|
-
ctx: ExtensionContext,
|
|
120
|
-
strict = true,
|
|
121
|
-
) => {
|
|
99
|
+
switchToRouterProfile: async (profileName: string, ctx: ExtensionContext, strict = true) => {
|
|
122
100
|
if (!currentConfig.profiles[profileName]) {
|
|
123
101
|
if (strict) {
|
|
124
102
|
ctx.ui.notify(`Unknown router profile: ${profileName}`, 'error');
|
|
@@ -146,7 +124,11 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
146
124
|
selectedProfile = profileName;
|
|
147
125
|
routerEnabled = true;
|
|
148
126
|
persistState();
|
|
149
|
-
|
|
127
|
+
try {
|
|
128
|
+
pi.setThinkingLevel('off');
|
|
129
|
+
} catch {
|
|
130
|
+
// Stale context
|
|
131
|
+
}
|
|
150
132
|
actions.updateStatus(ctx);
|
|
151
133
|
return true;
|
|
152
134
|
},
|
|
@@ -217,39 +199,26 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
217
199
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
218
200
|
|
|
219
201
|
routerEnabled = ctx.model?.provider === 'router';
|
|
220
|
-
selectedProfile = resolveProfileName(
|
|
221
|
-
currentConfig,
|
|
222
|
-
ctx.model?.provider === 'router' ? ctx.model.id : selectedProfile,
|
|
223
|
-
);
|
|
202
|
+
selectedProfile = resolveProfileName(currentConfig, ctx.model?.provider === 'router' ? ctx.model.id : selectedProfile);
|
|
224
203
|
pinnedTierByProfile = {};
|
|
225
204
|
lastNonRouterModel =
|
|
226
|
-
ctx.model && ctx.model.provider !== 'router'
|
|
227
|
-
? `${ctx.model.provider}/${ctx.model.id}`
|
|
228
|
-
: lastNonRouterModel;
|
|
205
|
+
ctx.model && ctx.model.provider !== 'router' ? `${ctx.model.provider}/${ctx.model.id}` : lastNonRouterModel;
|
|
229
206
|
|
|
230
207
|
const entries = ctx.sessionManager.getBranch() as CustomSessionEntry[];
|
|
231
208
|
const savedState = entries
|
|
232
|
-
.filter(
|
|
233
|
-
(entry) =>
|
|
234
|
-
entry.type === 'custom' && entry.customType === 'router-state',
|
|
235
|
-
)
|
|
209
|
+
.filter((entry) => entry.type === 'custom' && entry.customType === 'router-state')
|
|
236
210
|
.map((entry) => entry.data)
|
|
237
211
|
.findLast((data) => isRouterPersistedState(data));
|
|
238
212
|
|
|
239
213
|
if (isRouterPersistedState(savedState)) {
|
|
240
|
-
selectedProfile = resolveProfileName(
|
|
241
|
-
currentConfig,
|
|
242
|
-
savedState.selectedProfile,
|
|
243
|
-
);
|
|
214
|
+
selectedProfile = resolveProfileName(currentConfig, savedState.selectedProfile);
|
|
244
215
|
if (!selectedProfile) {
|
|
245
216
|
routerEnabled = false;
|
|
246
217
|
} else {
|
|
247
218
|
routerEnabled = savedState.enabled;
|
|
248
219
|
}
|
|
249
220
|
lastDecision = savedState.lastDecision;
|
|
250
|
-
pinnedTierByProfile = savedState.pinByProfile
|
|
251
|
-
? { ...savedState.pinByProfile }
|
|
252
|
-
: {};
|
|
221
|
+
pinnedTierByProfile = savedState.pinByProfile ? { ...savedState.pinByProfile } : {};
|
|
253
222
|
if (savedState.pinTier && selectedProfile) {
|
|
254
223
|
pinnedTierByProfile[selectedProfile] = savedState.pinTier;
|
|
255
224
|
}
|
|
@@ -319,7 +288,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
319
288
|
set debugEnabled(v) {
|
|
320
289
|
debugEnabled = v;
|
|
321
290
|
},
|
|
322
|
-
|
|
291
|
+
|
|
323
292
|
get lastConfigWarnings() {
|
|
324
293
|
return lastConfigWarnings;
|
|
325
294
|
},
|
|
@@ -327,6 +296,15 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
327
296
|
actions,
|
|
328
297
|
);
|
|
329
298
|
|
|
299
|
+
const ensureInitializedFromContext = (ctx: ExtensionContext) => {
|
|
300
|
+
if (!currentModelRegistry) {
|
|
301
|
+
currentModelRegistry = ctx.modelRegistry;
|
|
302
|
+
lastExtensionContext = ctx;
|
|
303
|
+
currentCwd = ctx.cwd;
|
|
304
|
+
actions.reloadConfig(ctx);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
330
308
|
pi.on('session_start', async (_event, ctx) => {
|
|
331
309
|
await restoreStateFromSession(ctx);
|
|
332
310
|
isInitialized = true;
|
|
@@ -336,14 +314,16 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
336
314
|
}
|
|
337
315
|
|
|
338
316
|
if (debugEnabled) {
|
|
339
|
-
ctx.ui.notify(
|
|
340
|
-
`Router initialized with profiles: ${profileNames(currentConfig).join(', ')}`,
|
|
341
|
-
'info',
|
|
342
|
-
);
|
|
317
|
+
ctx.ui.notify(`Router initialized with profiles: ${profileNames(currentConfig).join(', ')}`, 'info');
|
|
343
318
|
}
|
|
344
319
|
});
|
|
345
320
|
|
|
321
|
+
pi.on('turn_start', async (_event, ctx) => {
|
|
322
|
+
ensureInitializedFromContext(ctx);
|
|
323
|
+
});
|
|
324
|
+
|
|
346
325
|
pi.on('model_select', async (event, ctx) => {
|
|
326
|
+
ensureInitializedFromContext(ctx);
|
|
347
327
|
if (!isInitialized || isRouterDelegating) return;
|
|
348
328
|
if (event.model.provider === 'router') {
|
|
349
329
|
const profileName = resolveProfileName(currentConfig, event.model.id);
|
|
@@ -357,8 +337,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
357
337
|
const registryModel = ctx.modelRegistry.find('router', profileName);
|
|
358
338
|
if (
|
|
359
339
|
registryModel &&
|
|
360
|
-
(registryModel.contextWindow !== event.model.contextWindow ||
|
|
361
|
-
registryModel.maxTokens !== event.model.maxTokens)
|
|
340
|
+
(registryModel.contextWindow !== event.model.contextWindow || registryModel.maxTokens !== event.model.maxTokens)
|
|
362
341
|
) {
|
|
363
342
|
await setModelInternally(registryModel);
|
|
364
343
|
}
|
|
@@ -375,19 +354,28 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
375
354
|
});
|
|
376
355
|
|
|
377
356
|
pi.on('turn_end', async (_event, ctx) => {
|
|
357
|
+
ensureInitializedFromContext(ctx);
|
|
378
358
|
if (routerEnabled && selectedProfile && ctx.model?.provider !== 'router') {
|
|
379
359
|
const routerModel = ctx.modelRegistry.find('router', selectedProfile);
|
|
380
360
|
if (routerModel) {
|
|
381
361
|
const success = await setModelInternally(routerModel);
|
|
382
362
|
if (!success) {
|
|
383
|
-
|
|
363
|
+
try {
|
|
364
|
+
ctx.ui.notify('Failed to re-assert router model after turn. Router disabled.', 'warning');
|
|
365
|
+
} catch {
|
|
366
|
+
// Stale context
|
|
367
|
+
}
|
|
384
368
|
routerEnabled = false;
|
|
385
369
|
selectedProfile = undefined;
|
|
386
370
|
}
|
|
387
371
|
}
|
|
388
372
|
}
|
|
389
373
|
persistState();
|
|
390
|
-
|
|
374
|
+
try {
|
|
375
|
+
actions.updateStatus(ctx);
|
|
376
|
+
} catch {
|
|
377
|
+
// Stale context
|
|
378
|
+
}
|
|
391
379
|
});
|
|
392
380
|
};
|
|
393
381
|
|
package/extensions/provider.ts
CHANGED
|
@@ -5,15 +5,56 @@ import {
|
|
|
5
5
|
type Context,
|
|
6
6
|
createAssistantMessageEventStream,
|
|
7
7
|
type Model,
|
|
8
|
+
type ProviderHeaders,
|
|
8
9
|
type SimpleStreamOptions,
|
|
9
10
|
} from '@earendil-works/pi-ai';
|
|
10
|
-
import { streamSimple } from '@earendil-works/pi-ai/compat';
|
|
11
11
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
12
12
|
import type { RouterConfig, RouterPinByProfile, RouterTier, RoutingDecision } from './types';
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
createOpenRouterOnPayload,
|
|
15
|
+
dispatchStream,
|
|
16
|
+
OPENROUTER_ATTR_HEADERS,
|
|
17
|
+
parseCanonicalModelRef,
|
|
18
|
+
profileNames,
|
|
19
|
+
resolveModelFromRef,
|
|
20
|
+
ROUTER_TIERS,
|
|
21
|
+
} from './config';
|
|
22
|
+
import {
|
|
23
|
+
buildRoutingDecision,
|
|
24
|
+
countToolResultsSinceLastUserPrompt,
|
|
25
|
+
extractTextFromContent,
|
|
26
|
+
runClassifier,
|
|
27
|
+
shouldRunClassifier,
|
|
28
|
+
} from './routing';
|
|
15
29
|
import { formatDecision } from './ui';
|
|
16
30
|
|
|
31
|
+
const REGISTRY_WAIT_TIMEOUT_MS = 5000;
|
|
32
|
+
const REGISTRY_WAIT_INITIAL_DELAY_MS = 50;
|
|
33
|
+
const REGISTRY_WAIT_MAX_DELAY_MS = 500;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wait for the model registry to become available with exponential backoff.
|
|
37
|
+
* This handles race conditions where subagents invoke the router provider
|
|
38
|
+
* before session_start has fired in their context.
|
|
39
|
+
*/
|
|
40
|
+
export const waitForRegistry = async (
|
|
41
|
+
state: {
|
|
42
|
+
readonly currentModelRegistry: ExtensionContext['modelRegistry'] | undefined;
|
|
43
|
+
},
|
|
44
|
+
timeoutMs: number = REGISTRY_WAIT_TIMEOUT_MS,
|
|
45
|
+
): Promise<ExtensionContext['modelRegistry'] | undefined> => {
|
|
46
|
+
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
47
|
+
|
|
48
|
+
const start = Date.now();
|
|
49
|
+
let delay = REGISTRY_WAIT_INITIAL_DELAY_MS;
|
|
50
|
+
while (Date.now() - start < timeoutMs) {
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
52
|
+
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
53
|
+
delay = Math.min(delay * 2, REGISTRY_WAIT_MAX_DELAY_MS);
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
17
58
|
const createErrorMessage = (model: Model<Api>, message: string): AssistantMessage => {
|
|
18
59
|
return {
|
|
19
60
|
role: 'assistant',
|
|
@@ -40,7 +81,7 @@ const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
|
|
|
40
81
|
|
|
41
82
|
/**
|
|
42
83
|
* Truncate context to fit within a target token limit by removing oldest messages.
|
|
43
|
-
*
|
|
84
|
+
* Preserves the first system message and the latest user message, ensuring no orphaned tool results.
|
|
44
85
|
*/
|
|
45
86
|
const truncateContext = (context: Context, limit: number): Context => {
|
|
46
87
|
const messages = [...context.messages];
|
|
@@ -53,12 +94,19 @@ const truncateContext = (context: Context, limit: number): Context => {
|
|
|
53
94
|
const latestMessage = messages.pop()!;
|
|
54
95
|
const latestTokens = estimateTokens(extractTextFromContent(latestMessage.content));
|
|
55
96
|
|
|
56
|
-
let runningTotal =
|
|
97
|
+
let runningTotal =
|
|
98
|
+
systemTokens + latestTokens + messages.reduce((sum, m) => sum + estimateTokens(extractTextFromContent(m.content)), 0);
|
|
57
99
|
while (messages.length > 0 && runningTotal > limit) {
|
|
58
100
|
const shifted = messages.shift()!;
|
|
59
101
|
runningTotal -= estimateTokens(extractTextFromContent(shifted.content));
|
|
60
102
|
}
|
|
61
103
|
|
|
104
|
+
// Ensure the truncated context starts cleanly at a user prompt, rather than an orphaned toolResult
|
|
105
|
+
while (messages.length > 0 && messages[0].role === 'toolResult') {
|
|
106
|
+
const dropped = messages.shift()!;
|
|
107
|
+
runningTotal -= estimateTokens(extractTextFromContent(dropped.content));
|
|
108
|
+
}
|
|
109
|
+
|
|
62
110
|
return { ...context, messages: [...messages, latestMessage] };
|
|
63
111
|
};
|
|
64
112
|
|
|
@@ -123,9 +171,7 @@ export const registerRouterProvider = (
|
|
|
123
171
|
if (!tierConfig) return false;
|
|
124
172
|
return (
|
|
125
173
|
resolveModelFromRef(tierConfig.model, state.currentModelRegistry)?.reasoning ||
|
|
126
|
-
tierConfig.fallbacks?.some(
|
|
127
|
-
(fb) => resolveModelFromRef(fb, state.currentModelRegistry)?.reasoning,
|
|
128
|
-
)
|
|
174
|
+
tierConfig.fallbacks?.some((fb) => resolveModelFromRef(fb, state.currentModelRegistry)?.reasoning)
|
|
129
175
|
);
|
|
130
176
|
})
|
|
131
177
|
: false;
|
|
@@ -142,9 +188,9 @@ export const registerRouterProvider = (
|
|
|
142
188
|
});
|
|
143
189
|
|
|
144
190
|
if (state.currentModelRegistry) {
|
|
145
|
-
const invalidOverrides = Object.keys(
|
|
146
|
-
|
|
147
|
-
)
|
|
191
|
+
const invalidOverrides = Object.keys(state.currentConfig.contextThresholdPercentOverrides ?? {}).filter(
|
|
192
|
+
(modelRef) => !resolveModelFromRef(modelRef, state.currentModelRegistry),
|
|
193
|
+
);
|
|
148
194
|
|
|
149
195
|
if (invalidOverrides.length > 0) {
|
|
150
196
|
state.lastExtensionContext?.ui.notify(
|
|
@@ -157,25 +203,21 @@ export const registerRouterProvider = (
|
|
|
157
203
|
const loadedModelKeys = models.map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`).join(',');
|
|
158
204
|
if (state.lastLoadedModelKeys === loadedModelKeys) return; // models did not change, no need to re-register
|
|
159
205
|
|
|
160
|
-
pi.registerProvider(
|
|
161
|
-
|
|
206
|
+
pi.registerProvider('router', {
|
|
207
|
+
// config (baseUrl, apiKey, ...)
|
|
162
208
|
baseUrl: 'router://local',
|
|
163
209
|
apiKey: 'pi-model-router',
|
|
164
210
|
api: 'router-local-api',
|
|
165
211
|
models,
|
|
166
|
-
streamSimple: (
|
|
167
|
-
model: Model<Api>,
|
|
168
|
-
context: Context,
|
|
169
|
-
options?: SimpleStreamOptions,
|
|
170
|
-
): AssistantMessageEventStream => {
|
|
212
|
+
streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream => {
|
|
171
213
|
const stream = createAssistantMessageEventStream();
|
|
172
214
|
const ctx = state.lastExtensionContext;
|
|
173
|
-
const modelRegistry = state.currentModelRegistry;
|
|
174
215
|
|
|
175
216
|
(async () => {
|
|
176
217
|
try {
|
|
218
|
+
const modelRegistry = await waitForRegistry(state);
|
|
177
219
|
if (!modelRegistry) {
|
|
178
|
-
throw new Error('Router provider
|
|
220
|
+
throw new Error('Router provider initialization timed out. session_start may not have fired.');
|
|
179
221
|
}
|
|
180
222
|
const profile = currentConfig.profiles[model.id];
|
|
181
223
|
if (!profile) {
|
|
@@ -200,7 +242,8 @@ export const registerRouterProvider = (
|
|
|
200
242
|
lastDecision?.profile === model.id &&
|
|
201
243
|
lastDecision?.targetProvider === 'google' &&
|
|
202
244
|
lastDecision?.thinking !== 'off';
|
|
203
|
-
if (isGoogleContinuation) {
|
|
245
|
+
if (isGoogleContinuation) {
|
|
246
|
+
// Google thinking lock — preserve exact model on tool-result continuations
|
|
204
247
|
const toolResultsCount = countToolResultsSinceLastUserPrompt(context);
|
|
205
248
|
decision = {
|
|
206
249
|
...lastDecision!,
|
|
@@ -216,7 +259,15 @@ export const registerRouterProvider = (
|
|
|
216
259
|
if (currentConfig.classifierModels?.length && !pinnedTier) {
|
|
217
260
|
const toolResultsCount = countToolResultsSinceLastUserPrompt(context);
|
|
218
261
|
|
|
219
|
-
bShouldRunClassifier = shouldRunClassifier(
|
|
262
|
+
bShouldRunClassifier = shouldRunClassifier(
|
|
263
|
+
currentConfig,
|
|
264
|
+
context,
|
|
265
|
+
lastDecision,
|
|
266
|
+
lastMsgWasTool,
|
|
267
|
+
toolResultsCount,
|
|
268
|
+
state.debugEnabled,
|
|
269
|
+
ctx,
|
|
270
|
+
);
|
|
220
271
|
const classifierResult = bShouldRunClassifier
|
|
221
272
|
? await runClassifier(currentConfig, modelRegistry, context, lastDecision, ctx, state.debugEnabled)
|
|
222
273
|
: null;
|
|
@@ -295,7 +346,8 @@ export const registerRouterProvider = (
|
|
|
295
346
|
|
|
296
347
|
const thresholdPercent =
|
|
297
348
|
currentConfig.contextThresholdPercentOverrides?.[modelRef] ??
|
|
298
|
-
currentConfig.defaultContextThresholdPercent ??
|
|
349
|
+
currentConfig.defaultContextThresholdPercent ??
|
|
350
|
+
90;
|
|
299
351
|
const targetContextWindow = targetModel.contextWindow || 200_000;
|
|
300
352
|
const targetContextLimit = Math.floor((thresholdPercent / 100) * targetContextWindow);
|
|
301
353
|
const fitsContext = tokensUsed <= targetContextLimit;
|
|
@@ -315,7 +367,9 @@ export const registerRouterProvider = (
|
|
|
315
367
|
|
|
316
368
|
if (tier !== decision.tier) {
|
|
317
369
|
decision = buildRoutingDecision(
|
|
318
|
-
model.id,
|
|
370
|
+
model.id,
|
|
371
|
+
profile,
|
|
372
|
+
tier,
|
|
319
373
|
`Forced ${tier} tier because ${decision.tier} tier lacks models${triggerReasons ? ` for ${triggerReasons}` : ''}.`,
|
|
320
374
|
decision.lastClassifierRunToolCount,
|
|
321
375
|
);
|
|
@@ -330,10 +384,14 @@ export const registerRouterProvider = (
|
|
|
330
384
|
decision.isContextTriggered = !fitsContext;
|
|
331
385
|
|
|
332
386
|
if (ctx) {
|
|
333
|
-
|
|
334
|
-
|
|
387
|
+
try {
|
|
388
|
+
if (state.debugEnabled && bShouldRunClassifier) {
|
|
389
|
+
ctx.ui.notify(`Decision ${formatDecision(decision)}`, 'info');
|
|
390
|
+
}
|
|
391
|
+
actions.updateStatus(ctx);
|
|
392
|
+
} catch {
|
|
393
|
+
// Stale extension context
|
|
335
394
|
}
|
|
336
|
-
actions.updateStatus(ctx);
|
|
337
395
|
}
|
|
338
396
|
|
|
339
397
|
const auth = await modelRegistry.getApiKeyAndHeaders(targetModel);
|
|
@@ -351,20 +409,28 @@ export const registerRouterProvider = (
|
|
|
351
409
|
let effectiveContext = context;
|
|
352
410
|
if (!fitsContext) {
|
|
353
411
|
effectiveContext = truncateContext(context, targetContextLimit);
|
|
354
|
-
ctx?.ui.notify(
|
|
412
|
+
ctx?.ui.notify(
|
|
413
|
+
`Memory too large for ${modelRef} — trimmed ${context.messages.length - effectiveContext.messages.length} messages. Run /compact to reduce context size.`,
|
|
414
|
+
'warning',
|
|
415
|
+
);
|
|
355
416
|
}
|
|
356
417
|
|
|
357
418
|
// Attention: stripping reasoning from pi's incoming options so it doesn't leak into ...baseOptions. The router controls this via delegatedReasoning below.
|
|
358
419
|
const { onPayload, headers: originalHeaders, reasoning: _incomingReasoning, ...baseOptions } = options ?? {};
|
|
359
420
|
|
|
360
|
-
const effectiveHeaders:
|
|
361
|
-
...
|
|
362
|
-
...(auth.headers ?? {}),
|
|
421
|
+
const effectiveHeaders: ProviderHeaders = {
|
|
422
|
+
...targetModel.headers,
|
|
363
423
|
...(targetProvider === 'openrouter' ? OPENROUTER_ATTR_HEADERS : {}),
|
|
424
|
+
...auth.headers,
|
|
425
|
+
...originalHeaders,
|
|
364
426
|
};
|
|
365
427
|
|
|
366
428
|
const delegatedReasoning = targetModel.reasoning && decision.thinking !== 'off' ? decision.thinking : undefined;
|
|
367
|
-
|
|
429
|
+
try {
|
|
430
|
+
pi.setThinkingLevel(delegatedReasoning ?? 'off');
|
|
431
|
+
} catch {
|
|
432
|
+
// Stale extension context after session teardown
|
|
433
|
+
}
|
|
368
434
|
|
|
369
435
|
const effectiveOptions: SimpleStreamOptions = {
|
|
370
436
|
...baseOptions,
|
|
@@ -379,10 +445,16 @@ export const registerRouterProvider = (
|
|
|
379
445
|
effectiveOptions.onPayload = onPayload;
|
|
380
446
|
}
|
|
381
447
|
|
|
448
|
+
// Apply credential-specific baseUrl override if resolved by getApiKeyAndHeaders
|
|
449
|
+
// (e.g. GitHub Copilot business/enterprise tenant endpoints) to avoid 421 Misdirected Request.
|
|
450
|
+
const authBaseUrl = (auth as { baseUrl?: string }).baseUrl;
|
|
451
|
+
const requestModel = authBaseUrl ? { ...targetModel, baseUrl: authBaseUrl } : targetModel;
|
|
452
|
+
|
|
453
|
+
let eventsPushed = 0;
|
|
382
454
|
const MAX_ATTEMPTS_PER_MODEL = 2;
|
|
383
455
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS_PER_MODEL; attempt++) {
|
|
384
456
|
try {
|
|
385
|
-
const delegatedStream =
|
|
457
|
+
const delegatedStream = dispatchStream(requestModel, effectiveContext, effectiveOptions, modelRegistry);
|
|
386
458
|
let contentReceived = false;
|
|
387
459
|
for await (const event of delegatedStream) {
|
|
388
460
|
if (event.type === 'error' && !contentReceived) {
|
|
@@ -397,15 +469,23 @@ export const registerRouterProvider = (
|
|
|
397
469
|
contentReceived = true;
|
|
398
470
|
}
|
|
399
471
|
stream.push(event);
|
|
472
|
+
eventsPushed++;
|
|
400
473
|
}
|
|
401
474
|
success = true;
|
|
402
475
|
state.lastDecision = decision;
|
|
403
476
|
break attemptLoop;
|
|
404
477
|
} catch (err) {
|
|
405
478
|
lastError = err;
|
|
479
|
+
if (eventsPushed > 0) {
|
|
480
|
+
// Stream was already partially transmitted to the consumer; cannot retry or fall back safely.
|
|
481
|
+
throw err;
|
|
482
|
+
}
|
|
406
483
|
const remaining = MAX_ATTEMPTS_PER_MODEL - attempt;
|
|
407
484
|
const retryMsg = remaining > 0 ? ` — ${remaining} ${remaining === 1 ? 'retry' : 'retries'} left` : '';
|
|
408
|
-
ctx?.ui.notify(
|
|
485
|
+
ctx?.ui.notify(
|
|
486
|
+
`Failed to reach model ${modelRef} (attempt ${attempt}/${MAX_ATTEMPTS_PER_MODEL}): ${err}${retryMsg}`,
|
|
487
|
+
'warning',
|
|
488
|
+
);
|
|
409
489
|
}
|
|
410
490
|
}
|
|
411
491
|
}
|
|
@@ -414,20 +494,34 @@ export const registerRouterProvider = (
|
|
|
414
494
|
|
|
415
495
|
if (!success) {
|
|
416
496
|
const errorMsg = `Failed to delegate to any model in the chain.${failureReasons.length > 0 ? ' Reasons: ' + failureReasons.filter(Boolean).join('; ') + '.' : ''}`;
|
|
417
|
-
const
|
|
497
|
+
const lastErrorText = lastError instanceof Error ? lastError.message : String(lastError ?? '');
|
|
498
|
+
const combinedError = lastError ? new Error(`${lastErrorText} — ${errorMsg}`) : new Error(errorMsg);
|
|
418
499
|
throw combinedError;
|
|
419
500
|
}
|
|
420
501
|
|
|
421
502
|
stream.end();
|
|
422
503
|
} catch (error) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
504
|
+
const isStaleCtx = error instanceof Error && error.message.includes('stale');
|
|
505
|
+
if (isStaleCtx) {
|
|
506
|
+
stream.push({
|
|
507
|
+
type: 'done',
|
|
508
|
+
reason: 'stop',
|
|
509
|
+
message: createErrorMessage(model, ''),
|
|
510
|
+
});
|
|
511
|
+
} else {
|
|
512
|
+
stream.push({
|
|
513
|
+
type: 'error',
|
|
514
|
+
reason: 'error',
|
|
515
|
+
error: createErrorMessage(model, error instanceof Error ? error.message : String(error)),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
428
518
|
stream.end();
|
|
429
519
|
} finally {
|
|
430
|
-
|
|
520
|
+
try {
|
|
521
|
+
actions.persistState();
|
|
522
|
+
} catch {
|
|
523
|
+
// Ignore: extension context may be stale after session teardown.
|
|
524
|
+
}
|
|
431
525
|
}
|
|
432
526
|
})();
|
|
433
527
|
|
package/extensions/routing.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Context, Message } from '@earendil-works/pi-ai';
|
|
1
|
+
import type { Context, Message, ProviderHeaders, SimpleStreamOptions } from '@earendil-works/pi-ai';
|
|
3
2
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
4
3
|
import type { RouterConfig, RouterProfile, RouterTier, RoutingDecision } from './types';
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createOpenRouterOnPayload,
|
|
6
|
+
dispatchStream,
|
|
7
|
+
OPENROUTER_ATTR_HEADERS,
|
|
8
|
+
parseCanonicalModelRef,
|
|
9
|
+
resolveModelFromRef,
|
|
10
|
+
} from './config';
|
|
6
11
|
|
|
7
12
|
export const extractTextFromContent = (content: string | Message['content']): string => {
|
|
8
13
|
if (typeof content === 'string') {
|
|
@@ -27,7 +32,8 @@ const lastUserMessage = (context: Context): Message | undefined => {
|
|
|
27
32
|
return undefined;
|
|
28
33
|
};
|
|
29
34
|
|
|
30
|
-
const escapeXML = (s: string): string =>
|
|
35
|
+
const escapeXML = (s: string): string =>
|
|
36
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
31
37
|
|
|
32
38
|
const getRecentConversationText = (context: Context, limit: number): string => {
|
|
33
39
|
const messages = context.messages.slice(-limit);
|
|
@@ -239,25 +245,32 @@ export const runClassifier = async (
|
|
|
239
245
|
|
|
240
246
|
const isOpenRouter = parseCanonicalModelRef(classifierModelRef).provider === 'openrouter';
|
|
241
247
|
|
|
242
|
-
const
|
|
248
|
+
const effectiveClassifierHeaders: ProviderHeaders = {
|
|
249
|
+
...model.headers,
|
|
250
|
+
...(isOpenRouter ? OPENROUTER_ATTR_HEADERS : {}),
|
|
251
|
+
...auth.headers,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const classifierOptions: SimpleStreamOptions = {
|
|
243
255
|
apiKey: auth.apiKey,
|
|
244
|
-
headers:
|
|
245
|
-
...(auth.headers ?? {}),
|
|
246
|
-
...(isOpenRouter ? OPENROUTER_ATTR_HEADERS : {}),
|
|
247
|
-
},
|
|
256
|
+
headers: effectiveClassifierHeaders,
|
|
248
257
|
...(thinking && thinking !== 'off' ? { reasoning: thinking } : {}),
|
|
249
258
|
};
|
|
250
259
|
|
|
251
|
-
|
|
252
260
|
if (isOpenRouter) {
|
|
253
261
|
const onPayload = createOpenRouterOnPayload(extCtx?.sessionManager);
|
|
254
262
|
if (onPayload) classifierOptions.onPayload = onPayload;
|
|
255
263
|
}
|
|
256
264
|
|
|
265
|
+
// Apply credential-specific baseUrl override if resolved by getApiKeyAndHeaders
|
|
266
|
+
// (e.g. GitHub Copilot business/enterprise tenant endpoints) to avoid 421 Misdirected Request.
|
|
267
|
+
const authBaseUrl = (auth as { baseUrl?: string }).baseUrl;
|
|
268
|
+
const requestModel = authBaseUrl ? { ...model, baseUrl: authBaseUrl } : model;
|
|
269
|
+
|
|
257
270
|
const MAX_CLASSIFIER_ATTEMPTS = 3;
|
|
258
271
|
for (let attempt = 1; attempt <= MAX_CLASSIFIER_ATTEMPTS; attempt++) {
|
|
259
272
|
try {
|
|
260
|
-
const stream =
|
|
273
|
+
const stream = dispatchStream(requestModel, classifierContext, classifierOptions, modelRegistry);
|
|
261
274
|
let fullText = '';
|
|
262
275
|
for await (const event of stream) {
|
|
263
276
|
if (event.type === 'error') throw new Error(event.error?.errorMessage ?? 'Unknown classifier error');
|
package/extensions/state.ts
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
RouterPinByProfile,
|
|
3
|
-
RoutingDecision,
|
|
4
|
-
RouterPersistedState,
|
|
5
|
-
} from './types';
|
|
1
|
+
import type { RouterPinByProfile, RoutingDecision, RouterPersistedState } from './types';
|
|
6
2
|
|
|
7
|
-
export const isRouterPersistedState = (
|
|
8
|
-
value: unknown,
|
|
9
|
-
): value is RouterPersistedState => {
|
|
3
|
+
export const isRouterPersistedState = (value: unknown): value is RouterPersistedState => {
|
|
10
4
|
if (typeof value !== 'object' || value === null) {
|
|
11
5
|
return false;
|
|
12
6
|
}
|
|
13
7
|
const v = value as Record<string, unknown>;
|
|
14
|
-
return
|
|
15
|
-
typeof v.enabled === 'boolean' &&
|
|
16
|
-
typeof v.selectedProfile === 'string' &&
|
|
17
|
-
typeof v.timestamp === 'number'
|
|
18
|
-
);
|
|
8
|
+
return typeof v.enabled === 'boolean' && typeof v.selectedProfile === 'string' && typeof v.timestamp === 'number';
|
|
19
9
|
};
|
|
20
10
|
|
|
21
11
|
export const buildPersistedState = (
|
package/extensions/ui.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import type {
|
|
3
|
-
RoutingDecision,
|
|
4
|
-
RouterPinByProfile,
|
|
5
|
-
} from './types';
|
|
2
|
+
import type { RoutingDecision, RouterPinByProfile } from './types';
|
|
6
3
|
|
|
7
4
|
const getDecisionFlags = (decision: RoutingDecision): string[] => {
|
|
8
5
|
const flags: string[] = [];
|
|
@@ -15,9 +12,7 @@ export const formatDecision = (d: RoutingDecision): string => {
|
|
|
15
12
|
return `[${new Date(d.timestamp).toLocaleTimeString()}] ${d.tier} -> ${d.targetProvider}/${d.targetModelId} (${d.thinking}) - ${d.reasoning}`;
|
|
16
13
|
};
|
|
17
14
|
|
|
18
|
-
export const formatPinSummary = (
|
|
19
|
-
pinnedTierByProfile: RouterPinByProfile,
|
|
20
|
-
): string => {
|
|
15
|
+
export const formatPinSummary = (pinnedTierByProfile: RouterPinByProfile): string => {
|
|
21
16
|
const entries = Object.entries(pinnedTierByProfile)
|
|
22
17
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
23
18
|
.map(([profile, tier]) => `${profile}:${tier}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kdejaeger/pi-model-router",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Intelligent per-turn model router extension for the pi coding agent",
|
|
6
6
|
"keywords": [
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"prepublishOnly": "npm run tsc"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@earendil-works/pi-agent-core": "^0.80
|
|
46
|
-
"@earendil-works/pi-ai": "^0.80
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^0.80
|
|
48
|
-
"@earendil-works/pi-tui": "^0.80
|
|
45
|
+
"@earendil-works/pi-agent-core": "^0.80",
|
|
46
|
+
"@earendil-works/pi-ai": "^0.80",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.80",
|
|
48
|
+
"@earendil-works/pi-tui": "^0.80",
|
|
49
49
|
"@sinclair/typebox": "*"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"prettier": "^3.8
|
|
53
|
-
"typescript": "^6
|
|
52
|
+
"prettier": "^3.8",
|
|
53
|
+
"typescript": "^6"
|
|
54
54
|
}
|
|
55
55
|
}
|