@kylebrodeur/pi-model-router 0.1.2

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,583 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@mariozechner/pi-coding-agent';
5
+ import {
6
+ type RouterConfig,
7
+ type RouterPersistedState,
8
+ type RoutingDecision,
9
+ type RouterPinByProfile,
10
+ type RouterThinkingByProfile,
11
+ type RouterTier,
12
+ type CustomSessionEntry,
13
+ } from './types';
14
+ import {
15
+ FALLBACK_CONFIG,
16
+ loadRouterConfig,
17
+ profileNames,
18
+ resolveProfileName,
19
+ parseCanonicalModelRef,
20
+ } from './config';
21
+ import { MAX_DEBUG_HISTORY } from './constants';
22
+ import { isRouterPersistedState, buildPersistedState } from './state';
23
+ import { updateStatus, formatModelRef } from './ui';
24
+ import { registerCommands } from './commands';
25
+ import { registerRouterProvider } from './provider';
26
+ // ─── Feature modules (added by fork) ────────────────────────────────────────
27
+ import { initializeOllamaSync } from './ollama-sync';
28
+ import { initializeRateLimitFallback } from './rate-limit';
29
+
30
+ // ─── Plugin Detection & Progressive Integration ──────────────────────────
31
+ interface PluginStatus {
32
+ ledger: boolean;
33
+ agentBus: boolean;
34
+ }
35
+
36
+ const detectPlugins = (pi: ExtensionAPI): PluginStatus => {
37
+ const tools = (pi as any).tools ?? {};
38
+ const log = (pi as any).log;
39
+ return {
40
+ ledger: typeof tools.append_ledger === 'function',
41
+ agentBus: typeof tools.link_send === 'function',
42
+ };
43
+ };
44
+
45
+ const detectAndIntegratePlugins = (
46
+ pi: ExtensionAPI,
47
+ features: RouterConfig['features'],
48
+ debugEnabled: boolean,
49
+ ) => {
50
+ const plugins = detectPlugins(pi);
51
+ const log = (pi as any).log;
52
+
53
+ // Ledger integration: log routing decisions to qmd-ledger
54
+ const shouldIntegrateLedger = features?.ledgerIntegration === true;
55
+ if (shouldIntegrateLedger && plugins.ledger) {
56
+ log.info(
57
+ '[router] Progressive: qmd-ledger detected. Routing decisions will be logged.',
58
+ );
59
+ } else if (shouldIntegrateLedger && !plugins.ledger) {
60
+ log.warn(
61
+ '[router] ledgerIntegration enabled but qmd-ledger not detected. Install with: pi install npm:pi-qmd-ledger',
62
+ );
63
+ }
64
+
65
+ // Agent-bus integration: broadcast model changes
66
+ const shouldIntegrateAgentBus = features?.agentBusIntegration === true;
67
+ if (shouldIntegrateAgentBus && plugins.agentBus) {
68
+ log.info(
69
+ '[router] Progressive: pi-agent-bus detected. Model changes will be broadcast.',
70
+ );
71
+ } else if (shouldIntegrateAgentBus && !plugins.agentBus) {
72
+ log.warn(
73
+ '[router] agentBusIntegration enabled but pi-agent-bus not detected. Install with: pi install npm:pi-agent-bus',
74
+ );
75
+ }
76
+
77
+ if (debugEnabled) {
78
+ console.log('[router] Plugin detection:', plugins);
79
+ }
80
+ };
81
+
82
+ export interface RouterExtensionState {
83
+ routerEnabled: boolean;
84
+ selectedProfile: string;
85
+ pinnedTierByProfile: RouterPinByProfile;
86
+ thinkingByProfile: RouterThinkingByProfile;
87
+ accumulatedCost: number;
88
+ lastDecision: RoutingDecision | undefined;
89
+ lastNonRouterModel: string | undefined;
90
+ debugEnabled: boolean;
91
+ widgetEnabled: boolean;
92
+ debugHistory: RoutingDecision[];
93
+ }
94
+
95
+ export interface RouterExtensionActions {
96
+ switchToRouterProfile: (
97
+ profileName: string,
98
+ ctx: ExtensionContext,
99
+ strict?: boolean,
100
+ ) => Promise<boolean>;
101
+ reloadConfig: (
102
+ ctx?: ExtensionContext,
103
+ options?: { preserveDebug?: boolean },
104
+ ) => void;
105
+ ensureValidActiveRouterProfile: (ctx: ExtensionContext) => Promise<void>;
106
+ updateStatus: (ctx: ExtensionContext) => void;
107
+ persistState: () => void;
108
+ syncFeatures: () => void;
109
+ }
110
+
111
+ const routerExtension = (pi: ExtensionAPI) => {
112
+ let currentConfig: RouterConfig = FALLBACK_CONFIG;
113
+ let currentModelRegistry: ExtensionContext['modelRegistry'] | undefined;
114
+ let currentCwd = process.cwd();
115
+ let lastDecision: RoutingDecision | undefined;
116
+ let debugEnabled = false;
117
+ let routerEnabled = false;
118
+ let selectedProfile = resolveProfileName(
119
+ FALLBACK_CONFIG,
120
+ FALLBACK_CONFIG.defaultProfile,
121
+ );
122
+ let widgetEnabled = false;
123
+ let lastRegisteredModels = '';
124
+ let pinnedTierByProfile: RouterPinByProfile = {};
125
+ let thinkingByProfile: RouterThinkingByProfile = {};
126
+ let debugHistory: RoutingDecision[] = [];
127
+ let lastNonRouterModel: string | undefined;
128
+ let accumulatedCost = 0;
129
+ let lastExtensionContext: ExtensionContext | undefined;
130
+ let lastConfigWarnings: string[] = [];
131
+ let lastPersistedSnapshot: string | undefined;
132
+ let isInitialized = false;
133
+ let isInternalModelSwitch = false;
134
+
135
+ const setModelInternally = async (
136
+ model: NonNullable<ExtensionContext['model']>,
137
+ ) => {
138
+ isInternalModelSwitch = true;
139
+ try {
140
+ return await pi.setModel(model);
141
+ } finally {
142
+ isInternalModelSwitch = false;
143
+ }
144
+ };
145
+
146
+ const getPinnedTierForProfile = (
147
+ profileName: string,
148
+ ): RouterTier | undefined => pinnedTierByProfile[profileName];
149
+
150
+ const setPinnedTierForProfile = (
151
+ profileName: string,
152
+ tier: RouterTier | undefined,
153
+ ) => {
154
+ if (tier) {
155
+ pinnedTierByProfile[profileName] = tier;
156
+ } else {
157
+ delete pinnedTierByProfile[profileName];
158
+ }
159
+ };
160
+
161
+ const recordDebugDecision = (decision: RoutingDecision) => {
162
+ debugHistory = [...debugHistory, decision].slice(-MAX_DEBUG_HISTORY);
163
+ };
164
+
165
+ const getThinkingOverride = (profileName: string, tier: RouterTier) => {
166
+ return thinkingByProfile[profileName]?.[tier];
167
+ };
168
+
169
+ const persistState = () => {
170
+ const state = buildPersistedState(
171
+ routerEnabled,
172
+ selectedProfile,
173
+ pinnedTierByProfile,
174
+ thinkingByProfile,
175
+ debugEnabled,
176
+ widgetEnabled,
177
+ debugHistory,
178
+ lastDecision,
179
+ lastNonRouterModel,
180
+ accumulatedCost,
181
+ );
182
+ const snapshot = JSON.stringify({
183
+ ...state,
184
+ timestamp: 0,
185
+ lastDecision: state.lastDecision
186
+ ? { ...state.lastDecision, timestamp: 0 }
187
+ : undefined,
188
+ debugHistory: state.debugHistory?.map((decision) => ({
189
+ ...decision,
190
+ timestamp: 0,
191
+ })),
192
+ });
193
+ if (snapshot === lastPersistedSnapshot) {
194
+ return;
195
+ }
196
+ pi.appendEntry('router-state', state);
197
+ lastPersistedSnapshot = snapshot;
198
+ };
199
+
200
+ const actions = {
201
+ persistState,
202
+ updateStatus: (ctx: ExtensionContext) =>
203
+ updateStatus(
204
+ ctx,
205
+ routerEnabled,
206
+ selectedProfile,
207
+ pinnedTierByProfile,
208
+ thinkingByProfile,
209
+ lastDecision,
210
+ lastNonRouterModel,
211
+ accumulatedCost,
212
+ widgetEnabled,
213
+ currentConfig,
214
+ ),
215
+ reloadConfig: (
216
+ ctx?: ExtensionContext,
217
+ options?: { preserveDebug?: boolean },
218
+ ) => {
219
+ const loaded = loadRouterConfig(currentCwd);
220
+ currentConfig = loaded.config;
221
+ lastConfigWarnings = loaded.warnings;
222
+ if (!options?.preserveDebug) {
223
+ debugEnabled = currentConfig.debug ?? false;
224
+ }
225
+ selectedProfile = resolveProfileName(currentConfig, selectedProfile);
226
+ actions.registerRouterProvider();
227
+ if (ctx) {
228
+ actions.updateStatus(ctx);
229
+ }
230
+ },
231
+ ensureValidActiveRouterProfile: async (ctx: ExtensionContext) => {
232
+ if (ctx.model?.provider !== 'router') {
233
+ return;
234
+ }
235
+ if (currentConfig.profiles[ctx.model.id]) {
236
+ selectedProfile = ctx.model.id;
237
+ routerEnabled = true;
238
+ return;
239
+ }
240
+
241
+ const fallbackProfile = resolveProfileName(
242
+ currentConfig,
243
+ selectedProfile,
244
+ );
245
+ const routerModel = ctx.modelRegistry.find('router', fallbackProfile);
246
+ selectedProfile = fallbackProfile;
247
+ if (!routerModel) {
248
+ ctx.ui.notify(
249
+ `Router profile "${ctx.model.id}" is no longer configured.`,
250
+ 'warning',
251
+ );
252
+ return;
253
+ }
254
+
255
+ await setModelInternally(routerModel);
256
+ ctx.ui.notify(
257
+ `Router profile "${ctx.model.id}" is no longer configured. Switched to router/${fallbackProfile}.`,
258
+ 'warning',
259
+ );
260
+ },
261
+ switchToRouterProfile: async (
262
+ profileName: string,
263
+ ctx: ExtensionContext,
264
+ strict = true,
265
+ ) => {
266
+ if (strict && !currentConfig.profiles[profileName]) {
267
+ ctx.ui.notify(`Unknown router profile: ${profileName}`, 'error');
268
+ return false;
269
+ }
270
+ const resolvedProfile = resolveProfileName(currentConfig, profileName);
271
+
272
+ // Ensure the provider is registered with current capacities for this profile
273
+ actions.registerRouterProvider();
274
+ await new Promise((resolve) => setTimeout(resolve, 50));
275
+
276
+ const routerModel = ctx.modelRegistry.find('router', resolvedProfile);
277
+ if (!routerModel) {
278
+ ctx.ui.notify(`Unknown router profile: ${profileName}`, 'error');
279
+ return false;
280
+ }
281
+ if (ctx.model && ctx.model.provider !== 'router') {
282
+ lastNonRouterModel = `${ctx.model.provider}/${ctx.model.id}`;
283
+ }
284
+ const success = await setModelInternally(routerModel);
285
+ if (!success) {
286
+ ctx.ui.notify(`Failed to switch to router/${resolvedProfile}`, 'error');
287
+ return false;
288
+ }
289
+ selectedProfile = resolvedProfile;
290
+ routerEnabled = true;
291
+ persistState();
292
+ actions.updateStatus(ctx);
293
+ return true;
294
+ },
295
+ registerRouterProvider: () => {
296
+ registerRouterProvider(
297
+ pi,
298
+ {
299
+ get lastRegisteredModels() {
300
+ return lastRegisteredModels;
301
+ },
302
+ set lastRegisteredModels(v) {
303
+ lastRegisteredModels = v;
304
+ },
305
+ get currentConfig() {
306
+ return currentConfig;
307
+ },
308
+ get currentModelRegistry() {
309
+ return currentModelRegistry;
310
+ },
311
+ get lastExtensionContext() {
312
+ return lastExtensionContext;
313
+ },
314
+ get selectedProfile() {
315
+ return selectedProfile;
316
+ },
317
+ set selectedProfile(v) {
318
+ selectedProfile = v;
319
+ },
320
+ get routerEnabled() {
321
+ return routerEnabled;
322
+ },
323
+ set routerEnabled(v) {
324
+ routerEnabled = v;
325
+ },
326
+ get lastDecision() {
327
+ return lastDecision;
328
+ },
329
+ set lastDecision(v) {
330
+ lastDecision = v;
331
+ },
332
+ thinkingByProfile,
333
+ pinnedTierByProfile,
334
+ get accumulatedCost() {
335
+ return accumulatedCost;
336
+ },
337
+ set accumulatedCost(v) {
338
+ accumulatedCost = v;
339
+ },
340
+ },
341
+ {
342
+ persistState,
343
+ recordDebugDecision,
344
+ getThinkingOverride,
345
+ updateStatus: actions.updateStatus,
346
+ },
347
+ );
348
+ },
349
+ };
350
+
351
+ // ─── Feature sync (added by fork) ───────────────────────────────────────
352
+ const syncFeatures = () => {
353
+ const ctx = lastExtensionContext;
354
+ const features = currentConfig.features;
355
+
356
+ // Ollama sync
357
+ const shouldSyncOllama = !features || features.ollamaSync !== false; // enabled by default
358
+ if (shouldSyncOllama) {
359
+ initializeOllamaSync(
360
+ pi,
361
+ (currentConfig.ollamaSync ?? {}) as Record<string, unknown>,
362
+ );
363
+ }
364
+
365
+ // Rate limit fallback
366
+ const shouldInitRateLimit =
367
+ !features || features.rateLimitFallback !== false; // enabled by default
368
+ if (shouldInitRateLimit) {
369
+ initializeRateLimitFallback(
370
+ pi,
371
+ (currentConfig.rateLimitFallback as Record<string, unknown>) ?? {},
372
+ features?.contextCompression === true,
373
+ );
374
+ }
375
+
376
+ // ─── Progressive Plugin Integrations ───────────────────────────────────
377
+ detectAndIntegratePlugins(pi, features, debugEnabled);
378
+
379
+ if (debugEnabled) {
380
+ console.log(
381
+ '[router] Feature sync complete - ollama:',
382
+ shouldSyncOllama,
383
+ 'rate-limit:',
384
+ shouldInitRateLimit,
385
+ );
386
+ }
387
+ };
388
+
389
+ actions.reloadConfig();
390
+
391
+ const restoreStateFromSession = async (ctx: ExtensionContext) => {
392
+ lastExtensionContext = ctx;
393
+ currentModelRegistry = ctx.modelRegistry;
394
+ currentCwd = ctx.cwd;
395
+ actions.reloadConfig();
396
+
397
+ // Give the registry a moment to synchronize after re-registration
398
+ await new Promise((resolve) => setTimeout(resolve, 50));
399
+
400
+ routerEnabled = ctx.model?.provider === 'router';
401
+ selectedProfile = resolveProfileName(
402
+ currentConfig,
403
+ ctx.model?.provider === 'router' ? ctx.model.id : selectedProfile,
404
+ );
405
+ pinnedTierByProfile = {};
406
+ thinkingByProfile = {};
407
+ widgetEnabled = false;
408
+ debugHistory = [];
409
+ accumulatedCost = 0;
410
+ lastNonRouterModel =
411
+ ctx.model && ctx.model.provider !== 'router'
412
+ ? `${ctx.model.provider}/${ctx.model.id}`
413
+ : lastNonRouterModel;
414
+ lastDecision = undefined;
415
+
416
+ const entries = ctx.sessionManager.getBranch() as CustomSessionEntry[];
417
+ const savedState = entries
418
+ .filter(
419
+ (entry) =>
420
+ entry.type === 'custom' && entry.customType === 'router-state',
421
+ )
422
+ .map((entry) => entry.data)
423
+ .findLast((data) => isRouterPersistedState(data));
424
+
425
+ if (isRouterPersistedState(savedState)) {
426
+ selectedProfile = resolveProfileName(
427
+ currentConfig,
428
+ savedState.selectedProfile,
429
+ );
430
+ routerEnabled = savedState.enabled;
431
+ pinnedTierByProfile = savedState.pinByProfile
432
+ ? { ...savedState.pinByProfile }
433
+ : {};
434
+ thinkingByProfile = savedState.thinkingByProfile
435
+ ? { ...savedState.thinkingByProfile }
436
+ : {};
437
+ if (savedState.pinTier) {
438
+ pinnedTierByProfile[selectedProfile] = savedState.pinTier;
439
+ }
440
+ debugEnabled = savedState.debugEnabled ?? debugEnabled;
441
+ widgetEnabled = savedState.widgetEnabled ?? widgetEnabled;
442
+ debugHistory = savedState.debugHistory
443
+ ? [...savedState.debugHistory].slice(-MAX_DEBUG_HISTORY)
444
+ : [];
445
+ lastNonRouterModel = savedState.lastNonRouterModel ?? lastNonRouterModel;
446
+ accumulatedCost = savedState.accumulatedCost ?? 0;
447
+ }
448
+
449
+ await actions.ensureValidActiveRouterProfile(ctx);
450
+
451
+ if (routerEnabled) {
452
+ const routerModel = ctx.modelRegistry.find('router', selectedProfile);
453
+ if (routerModel) {
454
+ const success = await setModelInternally(routerModel);
455
+ if (!success) {
456
+ ctx.ui.notify(
457
+ `Failed to restore router/${selectedProfile} after relaunch.`,
458
+ 'warning',
459
+ );
460
+ routerEnabled = false;
461
+ }
462
+ } else {
463
+ ctx.ui.notify(
464
+ `Unable to restore router/${selectedProfile}; model is unavailable.`,
465
+ 'warning',
466
+ );
467
+ routerEnabled = false;
468
+ ctx.ui.setHiddenThinkingLabel?.();
469
+ }
470
+ } else {
471
+ ctx.ui.setHiddenThinkingLabel?.();
472
+ }
473
+
474
+ persistState();
475
+ actions.updateStatus(ctx);
476
+ };
477
+
478
+ registerCommands(
479
+ pi,
480
+ {
481
+ get currentConfig() {
482
+ return currentConfig;
483
+ },
484
+ get routerEnabled() {
485
+ return routerEnabled;
486
+ },
487
+ set routerEnabled(v) {
488
+ routerEnabled = v;
489
+ },
490
+ get selectedProfile() {
491
+ return selectedProfile;
492
+ },
493
+ set selectedProfile(v) {
494
+ selectedProfile = v;
495
+ },
496
+ pinnedTierByProfile,
497
+ thinkingByProfile,
498
+ get lastDecision() {
499
+ return lastDecision;
500
+ },
501
+ get lastNonRouterModel() {
502
+ return lastNonRouterModel;
503
+ },
504
+ set lastNonRouterModel(v) {
505
+ lastNonRouterModel = v;
506
+ },
507
+ get accumulatedCost() {
508
+ return accumulatedCost;
509
+ },
510
+ get debugEnabled() {
511
+ return debugEnabled;
512
+ },
513
+ set debugEnabled(v) {
514
+ debugEnabled = v;
515
+ },
516
+ get widgetEnabled() {
517
+ return widgetEnabled;
518
+ },
519
+ set widgetEnabled(v) {
520
+ widgetEnabled = v;
521
+ },
522
+ get debugHistory() {
523
+ return debugHistory;
524
+ },
525
+ },
526
+ actions,
527
+ );
528
+
529
+ pi.on('session_start', async (_event, ctx) => {
530
+ isInitialized = true;
531
+ await restoreStateFromSession(ctx);
532
+
533
+ // ─── Initialize features after state restore (added by fork) ─────
534
+ syncFeatures();
535
+
536
+ if (debugEnabled) {
537
+ ctx.ui.notify(
538
+ `Router initialized with profiles: ${profileNames(currentConfig).join(', ')}`,
539
+ 'info',
540
+ );
541
+ }
542
+ });
543
+
544
+ pi.on('model_select', async (event, ctx) => {
545
+ if (!isInitialized || isInternalModelSwitch) return;
546
+ if (event.model.provider === 'router') {
547
+ const profileName = resolveProfileName(currentConfig, event.model.id);
548
+
549
+ // If the selected model has stale capacities (e.g. from the initial registration),
550
+ // re-apply the model from the registry to force a TUI refresh.
551
+ const registryModel = ctx.modelRegistry.find('router', profileName);
552
+ if (
553
+ registryModel &&
554
+ (registryModel.contextWindow !== event.model.contextWindow ||
555
+ registryModel.maxTokens !== event.model.maxTokens)
556
+ ) {
557
+ await setModelInternally(registryModel);
558
+ }
559
+
560
+ routerEnabled = true;
561
+ selectedProfile = profileName;
562
+ } else {
563
+ routerEnabled = false;
564
+ lastNonRouterModel = `${event.model.provider}/${event.model.id}`;
565
+ ctx.ui.setHiddenThinkingLabel?.();
566
+ }
567
+ persistState();
568
+ actions.updateStatus(ctx);
569
+ });
570
+
571
+ pi.on('turn_end', async (_event, ctx) => {
572
+ if (routerEnabled && ctx.model?.provider !== 'router') {
573
+ const routerModel = ctx.modelRegistry.find('router', selectedProfile);
574
+ if (routerModel) {
575
+ await setModelInternally(routerModel);
576
+ }
577
+ }
578
+ persistState();
579
+ actions.updateStatus(ctx);
580
+ });
581
+ };
582
+
583
+ export default routerExtension;