@alexeiled/pi-model-router 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,573 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ SessionStartEvent,
6
+ } from '@earendil-works/pi-coding-agent';
7
+ import { registerCommands } from './commands';
8
+ import {
9
+ getUnsupportedTiers,
10
+ loadRouterConfig,
11
+ profileNames,
12
+ ROUTER_TIERS,
13
+ resolveProfileName,
14
+ } from './config';
15
+ import { MAX_DEBUG_HISTORY } from './constants';
16
+ import { registerRouterProvider } from './provider';
17
+ import {
18
+ buildPersistedState,
19
+ isRouterPersistedState,
20
+ loadLastRouterProfile,
21
+ saveLastRouterProfile,
22
+ } from './state';
23
+ import type {
24
+ CustomSessionEntry,
25
+ RouterConfig,
26
+ RouterPinByProfile,
27
+ RouterThinkingByProfile,
28
+ RouterTier,
29
+ RoutingDecision,
30
+ } from './types';
31
+ import { updateStatus } from './ui';
32
+
33
+ const hasExplicitCliModel = () =>
34
+ process.argv
35
+ .slice(2)
36
+ .some((arg) => arg === '--model' || arg.startsWith('--model='));
37
+
38
+ const routerExtension = (pi: ExtensionAPI) => {
39
+ let currentConfig: RouterConfig = { profiles: {} };
40
+ let currentModelRegistry: ExtensionContext['modelRegistry'] | undefined;
41
+ let currentCwd = process.cwd();
42
+ let lastDecision: RoutingDecision | undefined;
43
+ let debugEnabled = false;
44
+ let routerEnabled = false;
45
+ let selectedProfile: string | undefined;
46
+ let widgetEnabled = false;
47
+ let lastRegisteredModels = '';
48
+ const pinnedTierByProfile: RouterPinByProfile = {};
49
+ const thinkingByProfile: RouterThinkingByProfile = {};
50
+ let debugHistory: RoutingDecision[] = [];
51
+ let lastNonRouterModel: string | undefined;
52
+ let accumulatedCost = 0;
53
+ let lastExtensionContext: ExtensionContext | undefined;
54
+ let lastConfigWarnings: string[] = [];
55
+ let lastPersistedSnapshot: string | undefined;
56
+ let isInitialized = false;
57
+ let isInternalModelSwitch = false;
58
+ let isInternalThinkingChange = false;
59
+ let ignoreStartupThinkingEvent = false;
60
+
61
+ const setModelInternally = async (
62
+ model: NonNullable<ExtensionContext['model']>,
63
+ ) => {
64
+ isInternalModelSwitch = true;
65
+ try {
66
+ return await pi.setModel(model);
67
+ } catch {
68
+ // Extension context may be stale after session teardown.
69
+ return false;
70
+ } finally {
71
+ isInternalModelSwitch = false;
72
+ }
73
+ };
74
+
75
+ const setThinkingLevelInternally = (level: ThinkingLevel) => {
76
+ isInternalThinkingChange = true;
77
+ try {
78
+ pi.setThinkingLevel(level);
79
+ } catch {
80
+ // Extension context may be stale after session teardown.
81
+ } finally {
82
+ isInternalThinkingChange = false;
83
+ }
84
+ };
85
+
86
+ const recordDebugDecision = (decision: RoutingDecision) => {
87
+ debugHistory = [...debugHistory, decision].slice(-MAX_DEBUG_HISTORY);
88
+ };
89
+
90
+ const getThinkingOverride = (profileName: string, tier: RouterTier) => {
91
+ return thinkingByProfile[profileName]?.[tier];
92
+ };
93
+
94
+ const persistState = () => {
95
+ const state = buildPersistedState(
96
+ routerEnabled,
97
+ selectedProfile,
98
+ pinnedTierByProfile,
99
+ thinkingByProfile,
100
+ debugEnabled,
101
+ widgetEnabled,
102
+ debugHistory,
103
+ lastDecision,
104
+ lastNonRouterModel,
105
+ accumulatedCost,
106
+ );
107
+ const snapshot = JSON.stringify({
108
+ ...state,
109
+ timestamp: 0,
110
+ lastDecision: state.lastDecision
111
+ ? { ...state.lastDecision, timestamp: 0 }
112
+ : undefined,
113
+ debugHistory: state.debugHistory?.map((decision) => ({
114
+ ...decision,
115
+ timestamp: 0,
116
+ })),
117
+ });
118
+ if (snapshot === lastPersistedSnapshot) {
119
+ return;
120
+ }
121
+ try {
122
+ pi.appendEntry('router-state', state);
123
+ } catch {
124
+ // Defensive fallback: the session_shutdown event may fire after this
125
+ // code runs (due to event loop ordering), so isActive can still be
126
+ // true even though the runtime is already stale.
127
+ return;
128
+ }
129
+ lastPersistedSnapshot = snapshot;
130
+ };
131
+
132
+ const actions = {
133
+ persistState,
134
+ syncPiThinkingLevel: setThinkingLevelInternally,
135
+ updateStatus: (ctx: ExtensionContext) =>
136
+ updateStatus(
137
+ ctx,
138
+ routerEnabled,
139
+ selectedProfile,
140
+ pinnedTierByProfile,
141
+ thinkingByProfile,
142
+ lastDecision,
143
+ lastNonRouterModel,
144
+ accumulatedCost,
145
+ widgetEnabled,
146
+ currentConfig,
147
+ ),
148
+ reloadConfig: (
149
+ ctx?: ExtensionContext,
150
+ options?: { preserveDebug?: boolean },
151
+ ) => {
152
+ const loaded = loadRouterConfig(currentCwd);
153
+ currentConfig = loaded.config;
154
+ lastConfigWarnings = loaded.warnings;
155
+ if (!options?.preserveDebug) {
156
+ debugEnabled = currentConfig.debug ?? false;
157
+ }
158
+ selectedProfile = resolveProfileName(currentConfig, selectedProfile);
159
+ actions.registerRouterProvider();
160
+ if (ctx) {
161
+ actions.updateStatus(ctx);
162
+ if (lastConfigWarnings.length > 0) {
163
+ ctx.ui.notify(
164
+ `Router Configuration Warnings:\n${lastConfigWarnings.join('\n')}`,
165
+ 'warning',
166
+ );
167
+ }
168
+ }
169
+ },
170
+ ensureValidActiveRouterProfile: async (ctx: ExtensionContext) => {
171
+ if (ctx.model?.provider !== 'router') {
172
+ return;
173
+ }
174
+ if (currentConfig.profiles[ctx.model.id]) {
175
+ selectedProfile = ctx.model.id;
176
+ routerEnabled = true;
177
+ return;
178
+ }
179
+
180
+ // The active router model's profile no longer exists in config
181
+ ctx.ui.notify(
182
+ `Router profile "${ctx.model.id}" is no longer configured.`,
183
+ 'warning',
184
+ );
185
+ routerEnabled = false;
186
+ selectedProfile = undefined;
187
+ },
188
+ switchToRouterProfile: async (
189
+ profileName: string,
190
+ ctx: ExtensionContext,
191
+ strict = true,
192
+ ) => {
193
+ if (!currentConfig.profiles[profileName]) {
194
+ if (strict) {
195
+ ctx.ui.notify(`Unknown router profile: ${profileName}`, 'error');
196
+ }
197
+ return false;
198
+ }
199
+
200
+ // Ensure the provider is registered with current capacities for this profile
201
+ actions.registerRouterProvider();
202
+ await new Promise((resolve) => setTimeout(resolve, 50));
203
+
204
+ const routerModel = ctx.modelRegistry.find('router', profileName);
205
+ if (!routerModel) {
206
+ ctx.ui.notify(`Unknown router profile: ${profileName}`, 'error');
207
+ return false;
208
+ }
209
+ if (ctx.model && ctx.model.provider !== 'router') {
210
+ lastNonRouterModel = `${ctx.model.provider}/${ctx.model.id}`;
211
+ }
212
+ const success = await setModelInternally(routerModel);
213
+ if (!success) {
214
+ ctx.ui.notify(`Failed to switch to router/${profileName}`, 'error');
215
+ return false;
216
+ }
217
+ selectedProfile = profileName;
218
+ routerEnabled = true;
219
+ saveLastRouterProfile(profileName);
220
+ persistState();
221
+ actions.updateStatus(ctx);
222
+ return true;
223
+ },
224
+ registerRouterProvider: () => {
225
+ registerRouterProvider(
226
+ pi,
227
+ {
228
+ get lastRegisteredModels() {
229
+ return lastRegisteredModels;
230
+ },
231
+ set lastRegisteredModels(v) {
232
+ lastRegisteredModels = v;
233
+ },
234
+ get currentConfig() {
235
+ return currentConfig;
236
+ },
237
+ get currentModelRegistry() {
238
+ return currentModelRegistry;
239
+ },
240
+ get lastExtensionContext() {
241
+ return lastExtensionContext;
242
+ },
243
+ get selectedProfile() {
244
+ return selectedProfile;
245
+ },
246
+ set selectedProfile(v) {
247
+ selectedProfile = v;
248
+ },
249
+ get routerEnabled() {
250
+ return routerEnabled;
251
+ },
252
+ set routerEnabled(v) {
253
+ routerEnabled = v;
254
+ },
255
+ get lastDecision() {
256
+ return lastDecision;
257
+ },
258
+ set lastDecision(v) {
259
+ lastDecision = v;
260
+ },
261
+ thinkingByProfile,
262
+ pinnedTierByProfile,
263
+ get accumulatedCost() {
264
+ return accumulatedCost;
265
+ },
266
+ set accumulatedCost(v) {
267
+ accumulatedCost = v;
268
+ },
269
+ },
270
+ {
271
+ persistState,
272
+ recordDebugDecision,
273
+ getThinkingOverride,
274
+ updateStatus: actions.updateStatus,
275
+ syncPiThinkingLevel: setThinkingLevelInternally,
276
+ },
277
+ );
278
+ },
279
+ };
280
+
281
+ actions.reloadConfig();
282
+
283
+ const restoreStateFromSession = async (
284
+ ctx: ExtensionContext,
285
+ startReason: SessionStartEvent['reason'],
286
+ ) => {
287
+ ignoreStartupThinkingEvent =
288
+ startReason === 'startup' ||
289
+ startReason === 'new' ||
290
+ startReason === 'reload';
291
+ lastExtensionContext = ctx;
292
+ currentModelRegistry = ctx.modelRegistry;
293
+ currentCwd = ctx.cwd;
294
+ actions.reloadConfig(ctx);
295
+ const hasExplicitStartupModel =
296
+ startReason === 'startup' && hasExplicitCliModel();
297
+
298
+ // Give the registry a moment to synchronize after re-registration
299
+ await new Promise((resolve) => setTimeout(resolve, 50));
300
+
301
+ routerEnabled = ctx.model?.provider === 'router';
302
+ selectedProfile =
303
+ ctx.model?.provider === 'router'
304
+ ? resolveProfileName(currentConfig, ctx.model.id)
305
+ : resolveProfileName(currentConfig, selectedProfile);
306
+ // Clear in-place to keep references intact
307
+ for (const key of Object.keys(pinnedTierByProfile)) {
308
+ delete pinnedTierByProfile[key];
309
+ }
310
+ for (const key of Object.keys(thinkingByProfile)) {
311
+ delete thinkingByProfile[key];
312
+ }
313
+ widgetEnabled = false;
314
+ debugHistory = [];
315
+ accumulatedCost = 0;
316
+ lastNonRouterModel =
317
+ ctx.model && ctx.model.provider !== 'router'
318
+ ? `${ctx.model.provider}/${ctx.model.id}`
319
+ : lastNonRouterModel;
320
+ lastDecision = undefined;
321
+
322
+ await actions.ensureValidActiveRouterProfile(ctx);
323
+
324
+ const entries = ctx.sessionManager.getBranch() as CustomSessionEntry[];
325
+ const savedState = entries
326
+ .filter(
327
+ (entry) =>
328
+ entry.type === 'custom' && entry.customType === 'router-state',
329
+ )
330
+ .map((entry) => entry.data)
331
+ .findLast((data) => isRouterPersistedState(data));
332
+
333
+ if (isRouterPersistedState(savedState)) {
334
+ if (!hasExplicitStartupModel) {
335
+ selectedProfile = resolveProfileName(
336
+ currentConfig,
337
+ savedState.selectedProfile,
338
+ );
339
+ routerEnabled = savedState.enabled && selectedProfile !== undefined;
340
+ }
341
+ if (savedState.pinByProfile) {
342
+ Object.assign(pinnedTierByProfile, savedState.pinByProfile);
343
+ }
344
+ if (savedState.thinkingByProfile) {
345
+ Object.assign(thinkingByProfile, savedState.thinkingByProfile);
346
+ }
347
+ if (savedState.pinTier && selectedProfile) {
348
+ pinnedTierByProfile[selectedProfile] = savedState.pinTier;
349
+ }
350
+ debugEnabled = savedState.debugEnabled ?? debugEnabled;
351
+ widgetEnabled = savedState.widgetEnabled ?? widgetEnabled;
352
+ debugHistory = savedState.debugHistory
353
+ ? [...savedState.debugHistory].slice(-MAX_DEBUG_HISTORY)
354
+ : [];
355
+ if (!hasExplicitStartupModel) {
356
+ lastNonRouterModel =
357
+ savedState.lastNonRouterModel ?? lastNonRouterModel;
358
+ lastDecision = savedState.lastDecision;
359
+ }
360
+ accumulatedCost = savedState.accumulatedCost ?? 0;
361
+ } else if (
362
+ ctx.model?.provider === 'router' &&
363
+ (startReason === 'startup' || startReason === 'new') &&
364
+ !hasExplicitStartupModel
365
+ ) {
366
+ const lastProfile = resolveProfileName(
367
+ currentConfig,
368
+ loadLastRouterProfile(),
369
+ );
370
+ if (lastProfile) {
371
+ selectedProfile = lastProfile;
372
+ routerEnabled = true;
373
+ }
374
+ }
375
+
376
+ if (routerEnabled && selectedProfile) {
377
+ const routerModel = ctx.modelRegistry.find('router', selectedProfile);
378
+ if (routerModel) {
379
+ const success = await setModelInternally(routerModel);
380
+ if (!success) {
381
+ ctx.ui.notify(
382
+ `Failed to restore router/${selectedProfile} after relaunch.`,
383
+ 'warning',
384
+ );
385
+ routerEnabled = false;
386
+ } else if (lastDecision) {
387
+ // Sync pi's thinking level display with the router's last decision
388
+ setThinkingLevelInternally(lastDecision.thinking);
389
+ }
390
+ } else {
391
+ ctx.ui.notify(
392
+ `Unable to restore router/${selectedProfile}; model is unavailable.`,
393
+ 'warning',
394
+ );
395
+ routerEnabled = false;
396
+ ctx.ui.setHiddenThinkingLabel?.();
397
+ }
398
+ } else {
399
+ ctx.ui.setHiddenThinkingLabel?.();
400
+ }
401
+
402
+ persistState();
403
+ actions.updateStatus(ctx);
404
+ };
405
+
406
+ registerCommands(
407
+ pi,
408
+ {
409
+ get currentConfig() {
410
+ return currentConfig;
411
+ },
412
+ get routerEnabled() {
413
+ return routerEnabled;
414
+ },
415
+ set routerEnabled(v) {
416
+ routerEnabled = v;
417
+ },
418
+ get selectedProfile() {
419
+ return selectedProfile;
420
+ },
421
+ set selectedProfile(v) {
422
+ selectedProfile = v;
423
+ },
424
+ pinnedTierByProfile,
425
+ thinkingByProfile,
426
+ get lastDecision() {
427
+ return lastDecision;
428
+ },
429
+ get lastNonRouterModel() {
430
+ return lastNonRouterModel;
431
+ },
432
+ set lastNonRouterModel(v) {
433
+ lastNonRouterModel = v;
434
+ },
435
+ get accumulatedCost() {
436
+ return accumulatedCost;
437
+ },
438
+ get debugEnabled() {
439
+ return debugEnabled;
440
+ },
441
+ set debugEnabled(v) {
442
+ debugEnabled = v;
443
+ },
444
+ get widgetEnabled() {
445
+ return widgetEnabled;
446
+ },
447
+ set widgetEnabled(v) {
448
+ widgetEnabled = v;
449
+ },
450
+ get debugHistory() {
451
+ return debugHistory;
452
+ },
453
+ get lastConfigWarnings() {
454
+ return lastConfigWarnings;
455
+ },
456
+ },
457
+ actions,
458
+ );
459
+
460
+ pi.on('session_start', async (event, ctx) => {
461
+ isInitialized = true;
462
+ await restoreStateFromSession(ctx, event.reason);
463
+ if (debugEnabled) {
464
+ ctx.ui.notify(
465
+ `Router initialized with profiles: ${profileNames(currentConfig).join(', ')}`,
466
+ 'info',
467
+ );
468
+ }
469
+ });
470
+
471
+ // Eagerly initialize the model registry from any event that provides
472
+ // ExtensionContext. In subagent contexts (e.g. pi-dynamic-workflows),
473
+ // session_start may never fire, but turn_start/model_select fire before every LLM
474
+ // call — including the first call to the router provider's streamSimple.
475
+ // Only set when not already initialized: if extensions share instances across
476
+ // parent/subagent sessions, always overwriting would replace the parent's valid
477
+ // registry with the subagent's — which goes stale when the subagent ends.
478
+ const ensureInitializedFromContext = (ctx: ExtensionContext) => {
479
+ if (!currentModelRegistry) {
480
+ currentModelRegistry = ctx.modelRegistry;
481
+ lastExtensionContext = ctx;
482
+ currentCwd = ctx.cwd;
483
+ actions.reloadConfig(ctx);
484
+ }
485
+ };
486
+
487
+ pi.on('turn_start', async (_event, ctx) => {
488
+ ignoreStartupThinkingEvent = false;
489
+ ensureInitializedFromContext(ctx);
490
+ });
491
+
492
+ pi.on('model_select', async (event, ctx) => {
493
+ // Ensure the model registry is captured even if session_start hasn't fired
494
+ // (e.g. in subagent contexts spawned by pi-dynamic-workflows).
495
+ ensureInitializedFromContext(ctx);
496
+ if (!isInitialized || isInternalModelSwitch) return;
497
+ if (event.model.provider === 'router') {
498
+ const profileName = resolveProfileName(currentConfig, event.model.id);
499
+ if (!profileName) {
500
+ ctx.ui.notify(`Unknown router profile: ${event.model.id}`, 'error');
501
+ return;
502
+ }
503
+
504
+ // If the selected model has stale capacities (e.g. from the initial registration),
505
+ // re-apply the model from the registry to force a TUI refresh.
506
+ const registryModel = ctx.modelRegistry.find('router', profileName);
507
+ if (
508
+ registryModel &&
509
+ (registryModel.contextWindow !== event.model.contextWindow ||
510
+ registryModel.maxTokens !== event.model.maxTokens)
511
+ ) {
512
+ await setModelInternally(registryModel);
513
+ }
514
+
515
+ routerEnabled = true;
516
+ selectedProfile = profileName;
517
+ saveLastRouterProfile(profileName);
518
+ } else {
519
+ routerEnabled = false;
520
+ lastNonRouterModel = `${event.model.provider}/${event.model.id}`;
521
+ ctx.ui.setHiddenThinkingLabel?.();
522
+ }
523
+ persistState();
524
+ actions.updateStatus(ctx);
525
+ });
526
+
527
+ pi.on('turn_end', async (_event, ctx) => {
528
+ ensureInitializedFromContext(ctx);
529
+ if (routerEnabled && selectedProfile && ctx.model?.provider !== 'router') {
530
+ const routerModel = ctx.modelRegistry.find('router', selectedProfile);
531
+ if (routerModel) {
532
+ await setModelInternally(routerModel);
533
+ }
534
+ }
535
+ persistState();
536
+ actions.updateStatus(ctx);
537
+ });
538
+
539
+ pi.on('thinking_level_select', (event, ctx) => {
540
+ ensureInitializedFromContext(ctx);
541
+ if (!isInitialized || !routerEnabled || !selectedProfile) return;
542
+ if (isInternalThinkingChange) return;
543
+ if (ignoreStartupThinkingEvent) {
544
+ ignoreStartupThinkingEvent = false;
545
+ return;
546
+ }
547
+
548
+ // User changed pi's thinking level (e.g. via shift+tab).
549
+ // Apply as an all-tier thinking override for the active router profile.
550
+ thinkingByProfile[selectedProfile] ??= {};
551
+ const overrides = thinkingByProfile[selectedProfile];
552
+ for (const t of ROUTER_TIERS) {
553
+ overrides[t] = event.level;
554
+ }
555
+ persistState();
556
+ actions.updateStatus(ctx);
557
+ if (event.level !== 'off') {
558
+ const unsupported = getUnsupportedTiers(
559
+ currentConfig.profiles[selectedProfile],
560
+ event.level,
561
+ );
562
+ if (unsupported.length > 0) {
563
+ ctx.ui.notify(
564
+ `Router thinking (all) set to ${event.level}. ` +
565
+ `${unsupported.join(', ')} tier${unsupported.length > 1 ? 's' : ''} may not support '${event.level}'.`,
566
+ 'warning',
567
+ );
568
+ }
569
+ }
570
+ });
571
+ };
572
+
573
+ export default routerExtension;