@kdejaeger/pi-model-router 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +468 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/extensions/commands.ts +452 -0
- package/extensions/config.ts +405 -0
- package/extensions/index.ts +412 -0
- package/extensions/provider.ts +442 -0
- package/extensions/routing.ts +290 -0
- package/extensions/state.ts +39 -0
- package/extensions/types.ts +74 -0
- package/extensions/ui.ts +54 -0
- package/model-router.example.json +48 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ye Liu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# pi-model-router
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@kdejaeger/pi-model-router)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
**Intelligent per-turn model router extension** (forked from [yeliu84/pi-model-router](https://github.com/yeliu84/pi-model-router)) for the [pi-coding-agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent). Automatically selects between high, medium, and low-tier LLMs on every turn based on task intent and context size — with automatic fallbacks, image-aware rerouting, context truncation, and classifier-based tier selection.
|
|
7
|
+
|
|
8
|
+
> Think of it as an automatic transmission for your LLM -- it shifts gears up or down depending on what you're doing, so you never waste compute on a trivial task or run out of reasoning power on a complex one.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
- [How It Works](#how-it-works)
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Quick Start](#quick-start)
|
|
17
|
+
- [Configuration](#configuration)
|
|
18
|
+
- [Commands](#commands)
|
|
19
|
+
- [How Routing Works](#how-routing-works)
|
|
20
|
+
- [Example Configurations](#example-configurations)
|
|
21
|
+
- [Architecture](#architecture)
|
|
22
|
+
- [License](#license)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## How It Works
|
|
27
|
+
|
|
28
|
+
The pi-model-router registers itself as a **custom logical provider** (`router`) via `pi.registerProvider`. Each profile becomes a stable model (e.g., `router/balanced`). **The model shown in your footer stays fixed** while the underlying LLM changes per turn based on task complexity:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
footer shows: router/balanced
|
|
32
|
+
|
|
33
|
+
Turn 1: "Plan the architecture for the new API" → openai/gpt-5.4-pro (high)
|
|
34
|
+
Turn 2: "Yes, go ahead" → openai/gpt-5.4-pro (high)
|
|
35
|
+
Turn 3: "Implement the API handlers" → google/gemini-flash (med)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### As a user
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pi install npm:@kdejaeger/pi-model-router
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### For development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/kdejaeger/pi-model-router.git
|
|
52
|
+
cd pi-model-router
|
|
53
|
+
pi install .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or load directly for a single run:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pi -e ./extensions/index.ts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
1. **Install** the package (see above).
|
|
67
|
+
|
|
68
|
+
2. **Create a config file** at `.pi/model-router.json` in your project:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"profiles": {
|
|
73
|
+
"balanced": {
|
|
74
|
+
"high": { "model": "openai/gpt-5.4-pro", "thinking": "high" },
|
|
75
|
+
"medium": { "model": "google/gemini-flash-latest", "thinking": "medium" },
|
|
76
|
+
"low": { "model": "openai/gpt-5.4-nano", "thinking": "low" }
|
|
77
|
+
},
|
|
78
|
+
"cheap": {
|
|
79
|
+
"high": { "model": "google/gemini-flash-latest", "thinking": "low" },
|
|
80
|
+
"medium": { "model": "openai/gpt-5.4-nano", "thinking": "off" },
|
|
81
|
+
"low": { "model": "google/gemini-flash-lite-latest", "thinking": "off" }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
3. **Activate the router** -- choose one approach:
|
|
88
|
+
|
|
89
|
+
- **Runtime switch** (per session): Restart pi (or run `/router reload`), then run:
|
|
90
|
+
```
|
|
91
|
+
/router profile balanced
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- **Persistent activation** (all sessions): Add `router/balanced` (and/or `router/cheap`) to your [scoped models list](#activating-the-router) in pi's configuration. On restart, the router will load automatically with the profile you last used.
|
|
95
|
+
|
|
96
|
+
4. **Check the status:**
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
/router
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
### Config Locations
|
|
107
|
+
|
|
108
|
+
Config is loaded from two locations and **merged**:
|
|
109
|
+
|
|
110
|
+
| Location | Scope | Path |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| Global | User-wide | `~/.pi/agent/model-router.json` |
|
|
113
|
+
| Project | Per-project | `.pi/model-router.json` |
|
|
114
|
+
|
|
115
|
+
### Config Merging Order
|
|
116
|
+
|
|
117
|
+
Configs are merged: **Fallback defaults <- Global config <- Project config**.
|
|
118
|
+
|
|
119
|
+
Project config values override global values, which override built-in defaults. Profiles are merged **deeply** -- if you define only a `high` tier override for a profile in your project config, the `medium` and `low` tiers are inherited from the global config (or fallback defaults).
|
|
120
|
+
|
|
121
|
+
**When no config file exists**, the router loads with an empty profile list and no active models. Create a `.pi/model-router.json` with at least one profile to use the router.
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
### Configuration Fields
|
|
126
|
+
|
|
127
|
+
| Field | Type | Default | Description |
|
|
128
|
+
|---|---|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
129
|
+
| `debug` | `boolean` | `false` | Enable debug mode. Equivalent to running `/router debug on` at startup. |
|
|
130
|
+
| `classifierModels` | `string[]` | -- | Array of fast model refs (e.g. `["google/gemini-flash-latest"]`) used to classify user intent via LLM. Models are tried in order, providing fallback if one hits an error. When set, the classifier has final say on tier selection (gated by triggers below). Omit to use fast local heuristics only. |
|
|
131
|
+
| `classifierModelThinking` | `ThinkingLevel` | `off` | Reasoning/thinking level for the classifier model calls. Defaults to `off` (no extended reasoning) to keep calls fast and cheap. |
|
|
132
|
+
| `classifierRunOnceAfterToolCount` | `number` | `3` | Run the classifier once after this many tool continuations (only after the first user message of a turn). Default: 3. Set to 0 to disable. |
|
|
133
|
+
| `classifierRunAfterToolFailures` | `number` | `2` | Run the classifier after this many consecutive tool failures (counting from the tail of the current turn). Default: 2. |
|
|
134
|
+
| `classifierInterval` | `number` | `10` | Run the classifier every N tool continuations as a periodic re-check (crossed interval buckets). Default: 10. Set to 0 to disable. |
|
|
135
|
+
| `defaultContextThresholdPercent` | `number` | `90` | Default percentage threshold of a model's context window. If session context usage exceeds this percentage, the router searches for a suitable model in the current or higher tiers. |
|
|
136
|
+
| `contextThresholdPercentOverrides` | `Record<string, number>` | -- | **Optional.** Per-model context threshold overrides. Keys are canonical model refs in `"provider/model"` format. Values are the percentage of that model's context window that triggers an upgrade search. These take precedence over `defaultContextThresholdPercent`. Unknown keys produce a warning on provider registration. See [Context Threshold Overrides](#context-threshold-overrides). |
|
|
137
|
+
| `profiles` | `object` | _(required)_ | Map of profile definitions. |
|
|
138
|
+
|
|
139
|
+
### Profile Definitions
|
|
140
|
+
|
|
141
|
+
Each profile defines three **tiers** (`high`, `medium`, `low`). Each tier config:
|
|
142
|
+
|
|
143
|
+
| Field | Type | Default | Description |
|
|
144
|
+
|---|---|---|---|
|
|
145
|
+
| `model` | `string` | _(required)_ | Canonical model ref in `"provider/model"` format (e.g. `"openai/gpt-5.4-pro"`). |
|
|
146
|
+
| `thinking` | `ThinkingLevel` | -- | **Optional.** Reasoning/thinking level for this tier. |
|
|
147
|
+
| `fallbacks` | `string[]` | -- | **Optional.** Ordered list of fallback model refs. If the primary model fails, the router retries each fallback in sequence before surfacing an error. |
|
|
148
|
+
|
|
149
|
+
**Valid thinking levels** (from least to most reasoning): `off`, `minimal`, `low`, `medium`, `high`, `xhigh`
|
|
150
|
+
|
|
151
|
+
### Context Threshold Overrides
|
|
152
|
+
|
|
153
|
+
Use `contextThresholdPercentOverrides` to tune the context threshold for specific models. Keys must be canonical model refs in `"provider/model"` format. Values are percentages; lower values cause the router to consider upgrading sooner.
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"contextThresholdPercentOverrides": {
|
|
158
|
+
"openrouter/deepseek/deepseek-v4-flash": 60,
|
|
159
|
+
"openrouter/deepseek/deepseek-v4-pro": 50,
|
|
160
|
+
"openai-codex/gpt-5.5": 60,
|
|
161
|
+
"openrouter/google/gemini-3.1-pro-preview": 18
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
### Config Validation
|
|
169
|
+
|
|
170
|
+
The config system performs thorough validation on reload/startup and surfaces warnings via the notification system:
|
|
171
|
+
|
|
172
|
+
- Validates all profile model refs are in `provider/model` format
|
|
173
|
+
- Validates thinking levels against allowed values
|
|
174
|
+
- Reports missing/invalid profiles with fallback resolution
|
|
175
|
+
- Normalizes `defaultContextThresholdPercent` to positive values only
|
|
176
|
+
- Validates `contextThresholdPercentOverrides` keys against known models; unknown keys produce a warning on provider registration
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Activating the Router
|
|
181
|
+
|
|
182
|
+
When you define profiles in your config, each profile is registered as a model with the `router` provider — e.g., `router/balanced`, `router/cheap`. These models appear in pi's model list and are available for selection in pi's model switcher, just like any other model.
|
|
183
|
+
|
|
184
|
+
### 1. Persistent activation (on session start)
|
|
185
|
+
|
|
186
|
+
To have the router activate automatically every time pi starts:
|
|
187
|
+
|
|
188
|
+
1. Add the router model(s) to pi's **scoped models list** in your pi configuration (e.g., add `"router/balanced"`).
|
|
189
|
+
2. When pi starts with a router model in scope, the router activates using the profile name embedded in the model ID (e.g., `router/balanced` activates the `balanced` profile).
|
|
190
|
+
|
|
191
|
+
### 2. Runtime activation (current session only)
|
|
192
|
+
|
|
193
|
+
Once the extension is loaded, run `/router profile <name>` to switch to a router profile. This activates and remembers the router profile for the current session.
|
|
194
|
+
|
|
195
|
+
## Commands
|
|
196
|
+
|
|
197
|
+
All commands are accessible via `/router` in the pi chat interface. **Tab-completion is fully supported** for all subcommands and arguments.
|
|
198
|
+
|
|
199
|
+
### `/router status`
|
|
200
|
+
|
|
201
|
+
Show the current router status: enabled/disabled state, active profile and its pin, tier stickiness, last routing decision, debug mode, and history count.
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
/router
|
|
205
|
+
/router status
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### `/router profile [name]`
|
|
209
|
+
|
|
210
|
+
Switch to a different router profile. This automatically enables the router if it was disabled.
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
/router profile balanced # Switch to the 'balanced' profile
|
|
214
|
+
/router profile # List available profiles
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
If you call `/router` with a profile name directly (e.g. `/router balanced`), it also works as a shortcut.
|
|
218
|
+
|
|
219
|
+
### `/router pin [profile] <tier|clear>`
|
|
220
|
+
|
|
221
|
+
Force a specific tier for a profile, overriding all automatic routing decisions. **Pins are persisted in session state (branch-safe) but do NOT modify your config file.**
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
/router pin high # Pin current profile to 'high' tier
|
|
225
|
+
/router pin clear # Clear pin on current profile
|
|
226
|
+
/router pin cheap low # Pin the 'cheap' profile to 'low' tier
|
|
227
|
+
/router pin # Show current pin status
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Valid pin values: `high`, `medium`, `low`, `clear`.
|
|
231
|
+
|
|
232
|
+
> **Note:** `clear` removes the pin and returns the profile to automatic routing.
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
### `/router debug <on|off|show|clear>`
|
|
237
|
+
|
|
238
|
+
Control turn-by-turn routing debug notifications and history. Debug history stores the last 12 routing decisions.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
/router debug on # Enable
|
|
242
|
+
/router debug off # Disable
|
|
243
|
+
/router debug show # Show the last 12 routing decisions
|
|
244
|
+
/router debug clear # Clear history
|
|
245
|
+
/router debug # Toggle
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### `/router disable`
|
|
249
|
+
|
|
250
|
+
Disable the router and restore the **last used non-router model**.
|
|
251
|
+
|
|
252
|
+
### `/router reload`
|
|
253
|
+
|
|
254
|
+
**Hot-reload** the configuration from disk without restarting pi. Preserves debug state.
|
|
255
|
+
|
|
256
|
+
If the active profile was removed from the config, the router becomes inactive until you switch to an available profile via `/router profile <name>`.
|
|
257
|
+
|
|
258
|
+
### `/router help`
|
|
259
|
+
|
|
260
|
+
Show a comprehensive help listing of all subcommands.
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
/router help
|
|
264
|
+
/router ?
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## How Routing Works
|
|
270
|
+
|
|
271
|
+
For every turn, the router executes this ordered pipeline:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
GATE 0: GOOGLE LOCK
|
|
275
|
+
- Google thinking tool continuation? → preserve exact model/tier
|
|
276
|
+
(skips EVERYTHING below)
|
|
277
|
+
|
|
278
|
+
GATE 1: CLASSIFIER GATING (only when classifierModels is configured)
|
|
279
|
+
- Manual pin set → use pinned tier, classifier is skipped entirely
|
|
280
|
+
- No classifier result yet → default to `medium`
|
|
281
|
+
- New user message → run classifier
|
|
282
|
+
- Tool-result continuation?
|
|
283
|
+
├─ contCount >= classifierRunOnceAfterToolCount (first crossing)? → run once
|
|
284
|
+
├─ Consecutive failures >= classifierRunAfterToolFailures? → run (crisis)
|
|
285
|
+
├─ contCount % classifierInterval === 0? → run (periodic)
|
|
286
|
+
└─ Otherwise → reuse previous decision
|
|
287
|
+
|
|
288
|
+
POST-ROUTE CORRECTIONS (always apply)
|
|
289
|
+
- Image-aware escalation: upgrade tier if routed model
|
|
290
|
+
doesn't support image attachments
|
|
291
|
+
- Context threshold enforcement: if context usage exceeds
|
|
292
|
+
threshold, re-search for a suitable model in current or
|
|
293
|
+
higher tiers (strict pass first, truncation pass as fallback)
|
|
294
|
+
|
|
295
|
+
EXECUTION
|
|
296
|
+
- Auto-context truncation: trim oldest messages if target
|
|
297
|
+
model's window is smaller than the profile's maximum window
|
|
298
|
+
- Fallback chain: retry fallback models if primary fails
|
|
299
|
+
- Post-turn re-assert: re-select the router model after each turn if it was changed
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
### Classifier Gating
|
|
305
|
+
|
|
306
|
+
When the router has an LLM classifier configured (`classifierModels`), it doesn't run on every turn. Instead, the classifier is gated by smart triggers that avoid waste while catching real tier mismatches. **The classifier has final say on tier** (post-route corrections like image escalation).
|
|
307
|
+
|
|
308
|
+
| Gate | Trigger | Reason |
|
|
309
|
+
|---|---------------------------------------------------------------------------------------------|---|
|
|
310
|
+
| **Google lock** | Previous model was Google with thinking, and this is a tool-result continuation | Freezes to the same model — any change breaks thought-signature replay |
|
|
311
|
+
| **Fresh feedback** | Tool-continuation count first reaches or exceeds `classifierRunOnceAfterToolCount` (default: 3) | Captures the assistant's first response + tool result after a new user message |
|
|
312
|
+
| **Crisis** | Consecutive failed tool results (from the tail) ≥ `classifierRunAfterToolFailures` (default: 2) | Model is struggling — reclassify to potentially upgrade tier |
|
|
313
|
+
| **Interval** | Tool-continuation count % `classifierInterval` === 0 (default: 10) | Periodic re-check for long-running turns where initial assessment might be stale |
|
|
314
|
+
|
|
315
|
+
All counters reset per user turn — each new user message is treated as a fresh task. The crisis gate counts **consecutive** failures from the tail: one successful tool result resets the count to 0, so it only fires when failures are actually piling up.
|
|
316
|
+
|
|
317
|
+
When the classifier is skipped, the **previous routing decision** is reused directly. Post-route corrections (image escalation) still apply regardless.
|
|
318
|
+
|
|
319
|
+
### Context Controls
|
|
320
|
+
|
|
321
|
+
**Context & Image Requirements** (`defaultContextThresholdPercent`): When the conversation context exceeds this percentage of a model's window, or when images are detected, the router searches for a suitable model in the current or higher tiers. This check happens on every turn, but since context usage usually only grows across turns (unless compaction reduces it), the router will often stay in higher tiers once pushed there.
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
### Fallback Chains
|
|
326
|
+
|
|
327
|
+
Each tier can define `fallbacks` -- an ordered list of alternative models. If the primary model fails, the router retries each fallback in sequence before surfacing an error.
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
{
|
|
331
|
+
"profiles": {
|
|
332
|
+
"auto": {
|
|
333
|
+
"high": {
|
|
334
|
+
"model": "openai/gpt-5.4-pro",
|
|
335
|
+
"fallbacks": ["anthropic/claude-3-5-sonnet-20241022", "google/gemini-2.5-pro-latest"]
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
When a fallback is used, `decision.isFallback` is set to `true` and shown in the status. The tier's configured thinking level (or runtime override) applies to all fallback models -- if a fallback doesn't support the requested level, pi silently clamps it.
|
|
343
|
+
|
|
344
|
+
If a model fails during a turn, the router retries it up to **2 times** before moving to the next fallback in the chain.
|
|
345
|
+
|
|
346
|
+
### Image-Aware Auto-Routing
|
|
347
|
+
|
|
348
|
+
When the user attaches an image, the router checks whether the routed model supports image inputs. If not, it searches for another model in the same tier that does. If none are found, it escalates to higher tiers until a suitable model is found.
|
|
349
|
+
|
|
350
|
+
> **Note:** The search ensures that the selected model also fits the current context window requirements. To avoid landing on a model that can see images but not the full conversation, make sure your models have adequate context windows and image support configured.
|
|
351
|
+
|
|
352
|
+
### Google Thinking Tool Continuation
|
|
353
|
+
|
|
354
|
+
When using Google models with thinking enabled, tool-result continuations require the **same model** to avoid thought-signature replay errors. The router detects this pattern and preserves the exact model/tier for the continuation turn.
|
|
355
|
+
|
|
356
|
+
### Auto-Context Truncation
|
|
357
|
+
|
|
358
|
+
The router reports the **largest context window across all models in a profile** (scanning all tiers and their fallbacks for the maximum). When routing to a model with a smaller window, the router trims oldest messages (preserving the system prompt and the most recent message) to fit within the target model's limit.
|
|
359
|
+
|
|
360
|
+
Estimated using a conservative heuristic: **4 characters = 1 token**.
|
|
361
|
+
|
|
362
|
+
This is a rough last-resort cut, not a replacement for pi's built-in session compaction (`/compact`).
|
|
363
|
+
|
|
364
|
+
### Session & Debugging
|
|
365
|
+
|
|
366
|
+
**Persistent State:** Router state persists across agent restarts AND conversation branches via `pi.appendEntry` with a custom `router-state` entry type. Pins, debug mode, debug history, the last routing decision, and the last non-router model are all preserved. State is **branch-safe** -- different conversation branches maintain independent state using `sessionManager.getBranch()`.
|
|
367
|
+
|
|
368
|
+
**Status Line:** The router shows its status in the pi TUI status bar:
|
|
369
|
+
```
|
|
370
|
+
Router: enabled
|
|
371
|
+
Profile: balanced (active)
|
|
372
|
+
Pin: none
|
|
373
|
+
Route: medium -> google/gemini-flash-latest
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The `Route:` line may show decision flags in brackets when applicable:
|
|
377
|
+
- `[classifier]` — routed by the LLM classifier
|
|
378
|
+
|
|
379
|
+
- `[fallback]` — a fallback model was used
|
|
380
|
+
- `[context]` — context threshold triggered an upgrade
|
|
381
|
+
|
|
382
|
+
**Debug History:** With `/router debug on`, routing decisions and classifier gating decisions are logged with timestamps. View with `/router debug show` to see the routing history:
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
RUN classifier — init(≥3), interval(%10) (cont:5)
|
|
386
|
+
SKIP classifier (cont:2, fail:0)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The classifier gating notifications show why the classifier ran or was skipped (`cont` = tool-result continuations since the last user message, `fail` = consecutive recent tool failures).
|
|
390
|
+
```
|
|
391
|
+
[10:32:15 AM] high -> openai/gpt-5.4-pro (high) - Detected planning from keywords.
|
|
392
|
+
[10:33:42 AM] medium -> google/gemini-flash-latest (medium) - Detected implementation work.
|
|
393
|
+
[10:34:10 AM] low -> openai/gpt-5.4-nano (low) - Detected a short read-only lookup request.
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Example Configurations
|
|
399
|
+
|
|
400
|
+
### Balanced (`balanced`)
|
|
401
|
+
|
|
402
|
+
```json
|
|
403
|
+
{
|
|
404
|
+
"classifierModels": ["google/gemini-flash-latest"],
|
|
405
|
+
"defaultContextThresholdPercent": 70,
|
|
406
|
+
"profiles": {
|
|
407
|
+
"balanced": {
|
|
408
|
+
"high": { "model": "openai/gpt-5.4-pro", "thinking": "high", "fallbacks": ["anthropic/claude-3-5-sonnet-20241022"] },
|
|
409
|
+
"medium": { "model": "google/gemini-flash-latest", "thinking": "medium" },
|
|
410
|
+
"low": { "model": "openai/gpt-5.4-nano", "thinking": "low" }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Budget-Conscious (`cheap`)
|
|
417
|
+
|
|
418
|
+
```json
|
|
419
|
+
{
|
|
420
|
+
"profiles": {
|
|
421
|
+
"cheap": {
|
|
422
|
+
"high": { "model": "google/gemini-flash-latest", "thinking": "low" },
|
|
423
|
+
"medium": { "model": "openai/gpt-5.4-nano", "thinking": "off" },
|
|
424
|
+
"low": { "model": "google/gemini-flash-lite-latest", "thinking": "off" }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Deep Reasoning (`deep`)
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
{
|
|
434
|
+
"profiles": {
|
|
435
|
+
"deep": {
|
|
436
|
+
"high": { "model": "openai/o1-preview", "thinking": "xhigh" },
|
|
437
|
+
"medium": { "model": "openai/gpt-5.4-pro", "thinking": "medium" },
|
|
438
|
+
"low": { "model": "google/gemini-flash-latest", "thinking": "low" }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Anthropic-Only (`anthropic`)
|
|
445
|
+
|
|
446
|
+
```json
|
|
447
|
+
{
|
|
448
|
+
"profiles": {
|
|
449
|
+
"anthropic": {
|
|
450
|
+
"high": { "model": "anthropic/claude-3-5-sonnet-20241022", "thinking": "high" },
|
|
451
|
+
"medium": { "model": "anthropic/claude-3-5-sonnet-20241022", "thinking": "medium" },
|
|
452
|
+
"low": { "model": "anthropic/claude-3-haiku-20240307", "thinking": "low" }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Architecture
|
|
461
|
+
|
|
462
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for a detailed architectural deep dive, including the decision flow, module responsibilities, state persistence, and fallback chain design.
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## License
|
|
467
|
+
|
|
468
|
+
MIT (c) Ye Liu
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Architecture: Pi Model Router Extension
|
|
2
|
+
|
|
3
|
+
The `pi-model-router` registers a custom logical provider (`router`) that exposes "profiles" as models (e.g., `router/balanced`). For every turn, the router selects an underlying concrete model based on task complexity and conversation context.
|
|
4
|
+
|
|
5
|
+
> For the full decision-pipeline reference (context controls, fallback chains, image-aware escalation, Google thinking tool continuation, auto-context truncation, and thinking control), see [How Routing Works](../README.md#how-routing-works) in the README.
|
|
6
|
+
|
|
7
|
+
## Module Architecture
|
|
8
|
+
|
|
9
|
+
The extension is modularized for maintainability:
|
|
10
|
+
|
|
11
|
+
- **`extensions/index.ts`**: Orchestrator. Manages state, hooks into `pi` events, and wires modules together.
|
|
12
|
+
- **`extensions/provider.ts`**: Implements the `router` provider and the delegation/retry loop.
|
|
13
|
+
- **`extensions/routing.ts`**: Core decision logic, classifier, and gating.
|
|
14
|
+
- **`extensions/config.ts`**: Loads, merges, and normalizes the JSON configuration.
|
|
15
|
+
- **`extensions/commands.ts`**: Registers all `/router` subcommands and their autocompletions.
|
|
16
|
+
- **`extensions/ui.ts`**: Manages the status line and the optional state widget.
|
|
17
|
+
- **`extensions/state.ts`**: Handles session-persisted state and snapshots.
|
|
18
|
+
- **`extensions/types.ts`**: Centralized interface and type definitions.
|
|
19
|
+
|
|
20
|
+
### Data Flow
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
session_start / model_select / turn_end (index.ts)
|
|
24
|
+
pi runtime calls router provider on turn
|
|
25
|
+
│
|
|
26
|
+
▼
|
|
27
|
+
provider.ts streamSimple
|
|
28
|
+
│
|
|
29
|
+
├─→ Google lock → preserve exact model for tool continuation
|
|
30
|
+
├─→ routing.ts
|
|
31
|
+
├─→ Classifier gating (only when classifierModels configured):
|
|
32
|
+
│ ├─ New user message? → run classifier (final say)
|
|
33
|
+
│ ├─ Tool cont ≥ confInitN (first crossing)? → run once
|
|
34
|
+
│ ├─ Consecutive failures ≥ confFailN? → run (crisis)
|
|
35
|
+
│ ├─ Cont crosses new interval bucket? → run (periodic)
|
|
36
|
+
│ └─ Otherwise → reuse previous decision
|
|
37
|
+
│
|
|
38
|
+
├─→ Post-route corrections (image escalation)
|
|
39
|
+
├─→ Auto-context truncation
|
|
40
|
+
├─→ Delegate to target model
|
|
41
|
+
└─→ Fallback chain on failure
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
ui.ts (update status line + widget)
|
|
45
|
+
state.ts (persist decision, history)
|
|
46
|
+
|
|
47
|
+
session_start / model_select / turn_end (index.ts)
|
|
48
|
+
only restore, validate, or reassert the router model;
|
|
49
|
+
they do not perform routing themselves.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## State & Persistence
|
|
53
|
+
|
|
54
|
+
Router state is persisted using `pi.appendEntry` with a custom type `router-state`. This allows the router to:
|
|
55
|
+
|
|
56
|
+
- Restore the active profile and pins across agent relaunches.
|
|
57
|
+
- Maintain independent pins and state for different conversation branches via `sessionManager.getBranch()`.
|
|
58
|
+
- Maintains session state across restarts.
|
|
59
|
+
|
|
60
|
+
### Persisted Fields
|
|
61
|
+
|
|
62
|
+
| Field | Type | Description |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `selectedProfile` | `string` | Active profile name |
|
|
65
|
+
| `pinnedTierByProfile` | `Record<string, TierLevel>` | Manual tier pins per profile |
|
|
66
|
+
| `debugEnabled` | `boolean` | Debug mode state |
|
|
67
|
+
| `lastDecision` | `RoutingDecision` | Most recent routing decision |
|
|
68
|
+
| `lastNonRouterModel` | `string` | Last model used before switching to router |
|
|
69
|
+
| `debugHistory` | `RoutingDecision[]` | Recent routing decisions |
|
|
70
|
+
|
|
71
|
+
> **Branch safety**: Because state is saved via `pi.appendEntry`, each conversation branch gets its own independent state. Switching branches restores the pins and history that were active on that branch.
|
|
72
|
+
|
|
73
|
+
### Debug History
|
|
74
|
+
|
|
75
|
+
The debug history stores the last 12 routing decisions. When debug mode is enabled (`/router debug on`), each decision is appended to `debugHistory` and `/router debug show` prints the full history.
|