@kdejaeger/pi-model-router 0.3.1 → 0.4.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/README.md CHANGED
@@ -65,7 +65,7 @@ pi -e ./extensions/index.ts
65
65
 
66
66
  1. **Install** the package (see above).
67
67
 
68
- 2. **Create a config file** at `.pi/model-router.json` in your project:
68
+ 2. **Create a config file** at `.pi/model-router.json` in your project (bigger example configs later in this document):
69
69
 
70
70
  ```json
71
71
  {
@@ -114,9 +114,9 @@ Config is loaded from two locations and **merged**:
114
114
 
115
115
  ### Config Merging Order
116
116
 
117
- Configs are merged: **Fallback defaults <- Global config <- Project config**.
117
+ Configs are merged: **Base defaults <- Global config <- Project config**.
118
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).
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.
120
120
 
121
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
122
 
@@ -127,7 +127,7 @@ Project config values override global values, which override built-in defaults.
127
127
  | Field | Type | Default | Description |
128
128
  |---|---|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
129
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. |
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 always default to `medium` tier (no automatic tier switching). |
131
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
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
133
  | `classifierRunAfterToolFailures` | `number` | `2` | Run the classifier after this many consecutive tool failures (counting from the tail of the current turn). Default: 2. |
@@ -146,7 +146,7 @@ Each profile defines three **tiers** (`high`, `medium`, `low`). Each tier config
146
146
  | `thinking` | `ThinkingLevel` | -- | **Optional.** Reasoning/thinking level for this tier. |
147
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
148
 
149
- **Valid thinking levels** (from least to most reasoning): `off`, `minimal`, `low`, `medium`, `high`, `xhigh`
149
+ **Valid thinking levels** (from least to most reasoning): `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`
150
150
 
151
151
  ### Context Threshold Overrides
152
152
 
@@ -341,7 +341,7 @@ Each tier can define `fallbacks` -- an ordered list of alternative models. If th
341
341
 
342
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
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.
344
+ If a model fails during a turn, the router retries it **once** (2 total attempts) before moving to the next fallback in the chain.
345
345
 
346
346
  ### Image-Aware Auto-Routing
347
347
 
@@ -357,7 +357,7 @@ When using Google models with thinking enabled, tool-result continuations requir
357
357
 
358
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
359
 
360
- Estimated using a conservative heuristic: **4 characters = 1 token**.
360
+ Conservative estimation: **4 characters = 1 token**.
361
361
 
362
362
  This is a rough last-resort cut, not a replacement for pi's built-in session compaction (`/compact`).
363
363
 
@@ -365,91 +365,118 @@ This is a rough last-resort cut, not a replacement for pi's built-in session com
365
365
 
366
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
367
 
368
- **Status Line:** The router shows its status in the pi TUI status bar:
368
+ **Status Line:** The router shows its status as a single line in the pi TUI status bar, e.g.:
369
369
  ```
370
- Router: enabled
371
- Profile: balanced (active)
372
- Pin: none
373
- Route: medium -> google/gemini-flash-latest
370
+ ⇋ medium -> google/gemini-flash-latest
374
371
  ```
375
372
 
376
- The `Route:` line may show decision flags in brackets when applicable:
377
- - `[classifier]` — routed by the LLM classifier
378
-
373
+ The status line may show decision flags in brackets when applicable:
379
374
  - `[fallback]` — a fallback model was used
380
375
  - `[context]` — context threshold triggered an upgrade
381
376
 
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:
377
+ **Debug History:** With `/router debug on`, routing decisions and classifier runs are logged with timestamps. View with `/router debug show` to see the routing history:
383
378
 
384
379
  ```
385
- RUN classifier — init(≥3), interval(%10) (cont:5)
386
- SKIP classifier (cont:2, fail:0)
380
+ Running router classifier — init(≥3), interval(%10) (cont:5) ...
387
381
  ```
388
382
 
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).
383
+ The classifier notification shows why the classifier was triggered (`cont` = tool-result continuations since the last user message, `fail` = consecutive recent tool failures).
390
384
  ```
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.
385
+ [10:32:15 AM] high -> openai/gpt-5.4-pro (high) - Classifier: multi-file architecture change across 4 services requires careful trade-off analysis.
386
+ [10:33:42 AM] medium -> google/gemini-flash-latest (medium) - Classifier: implementing a well-defined feature with clear acceptance criteria.
387
+ [10:34:10 AM] low -> openai/gpt-5.4-nano (low) - Classifier: simple field rename with no behavioral changes.
394
388
  ```
395
389
 
396
390
  ---
397
391
 
398
- ## Example Configurations
399
-
400
- ### Balanced (`balanced`)
392
+ ## Example Configuration
401
393
 
402
394
  ```json
403
395
  {
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
- ```
396
+ "debug": true,
397
+ "classifierModels": ["openrouter/deepseek/deepseek-v4-flash"],
398
+ "classifierModelThinking": "high",
399
+ "defaultContextThresholdPercent": 80,
400
+ "contextThresholdPercentOverrides": {
401
+ "opencode/deepseek-v4-flash-free": 60,
402
+ "openrouter/deepseek/deepseek-v4-flash": 60,
403
+ "openrouter/deepseek/deepseek-v4-pro": 50,
415
404
 
416
- ### Budget-Conscious (`cheap`)
405
+ "openrouter/google/gemma-4-31b-it": 50,
406
+ "openrouter/google/gemini-3-flash-preview": 80,
407
+ "openrouter/google/gemini-3.5-flash": 80,
408
+ "openrouter/google/gemini-3.1-pro-preview": 18,
417
409
 
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
- ```
410
+ "openrouter/moonshotai/kimi-k2.6": 70,
411
+ "openrouter/moonshotai/kimi-k2.7-code": 60,
429
412
 
430
- ### Deep Reasoning (`deep`)
413
+ "openrouter/minimax/minimax-m3": 50,
431
414
 
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
- ```
415
+ "openrouter/z-ai/glm-5.2": 50,
443
416
 
444
- ### Anthropic-Only (`anthropic`)
417
+ "openai-codex/gpt-5.4-mini": 60,
418
+ "openai-codex/gpt-5.5": 60,
419
+ "openai-codex/gpt-5.6-luna": 65,
420
+ "openai-codex/gpt-5.6-terra": 65,
421
+ "openai-codex/gpt-5.6-sol": 65,
422
+ "openrouter/openai/gpt-5.4-nano": 30,
445
423
 
446
- ```json
447
- {
424
+ "openrouter/anthropic/claude-opus-4.8": 65,
425
+
426
+ "openrouter/x-ai/grok-4.5": 50
427
+ },
448
428
  "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" }
429
+ "cheap": {
430
+ "high": {
431
+ "model": "openrouter/deepseek/deepseek-v4-flash", "thinking": "xhigh",
432
+ "fallbacks": ["openrouter/minimax/minimax-m3"]
433
+ },
434
+ "medium": {
435
+ "model": "openrouter/deepseek/deepseek-v4-flash", "thinking": "high",
436
+ "fallbacks": ["openrouter/minimax/minimax-m3"]
437
+ },
438
+ "low": {
439
+ "model": "openrouter/deepseek/deepseek-v4-flash", "thinking": "off",
440
+ "fallbacks": ["openrouter/minimax/minimax-m3"]
441
+ }
442
+ },
443
+ "balanced": {
444
+ "high": {
445
+ "model": "openrouter/z-ai/glm-5.2", "thinking": "xhigh",
446
+ "fallbacks": ["openrouter/minimax/minimax-m3"]
447
+ },
448
+ "medium": {
449
+ "model": "openrouter/deepseek/deepseek-v4-flash", "thinking": "xhigh",
450
+ "fallbacks": ["openrouter/minimax/minimax-m3"]
451
+ },
452
+ "low": {
453
+ "model": "openrouter/deepseek/deepseek-v4-flash", "thinking": "off",
454
+ "fallbacks": ["openrouter/minimax/minimax-m3"]
455
+ }
456
+ },
457
+ "expensive": {
458
+ "high": {
459
+ "model": "openrouter/z-ai/glm-5.2", "thinking": "max",
460
+ "fallbacks": ["openrouter/x-ai/grok-4.5", "openai/gpt-5.6-luna"]
461
+ },
462
+ "medium": {
463
+ "model": "openrouter/z-ai/glm-5.2", "thinking": "max",
464
+ "fallbacks": ["openai/gpt-5.6-luna", "openrouter/x-ai/grok-4.5"]
465
+ },
466
+ "low": {
467
+ "model": "openrouter/minimax/minimax-m3", "thinking": "off",
468
+ "fallbacks": ["openrouter/z-ai/glm-5.2"]
469
+ }
470
+ },
471
+ "gpt-5.6": {
472
+ "high": { "model": "openai/gpt-5.6-sol", "thinking": "medium" },
473
+ "medium": { "model": "openai/gpt-5.6-terra", "thinking": "high" },
474
+ "low": { "model": "openai/gpt-5.6-luna", "thinking": "high" }
475
+ },
476
+ "opus-4.8": {
477
+ "high": { "model": "openrouter/anthropic/claude-opus-4.8", "thinking": "medium" },
478
+ "medium": { "model": "openrouter/anthropic/claude-opus-4.8", "thinking": "low" },
479
+ "low": { "model": "openrouter/anthropic/claude-opus-4.8", "thinking": "off" }
453
480
  }
454
481
  }
455
482
  }
@@ -460,9 +487,3 @@ The classifier gating notifications show why the classifier ran or was skipped (
460
487
  ## Architecture
461
488
 
462
489
  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
@@ -62,7 +62,7 @@ Router state is persisted using `pi.appendEntry` with a custom type `router-stat
62
62
  | Field | Type | Description |
63
63
  |---|---|---|
64
64
  | `selectedProfile` | `string` | Active profile name |
65
- | `pinnedTierByProfile` | `Record<string, TierLevel>` | Manual tier pins per profile |
65
+ | `pinnedTierByProfile` | `Record<string, RouterTier>` | Manual tier pins per profile |
66
66
  | `debugEnabled` | `boolean` | Debug mode state |
67
67
  | `lastDecision` | `RoutingDecision` | Most recent routing decision |
68
68
  | `lastNonRouterModel` | `string` | Last model used before switching to router |
@@ -14,7 +14,7 @@ import type {
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'];
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
20
  const isObjectRecord = (
@@ -6,8 +6,8 @@ import {
6
6
  createAssistantMessageEventStream,
7
7
  type Model,
8
8
  type SimpleStreamOptions,
9
- streamSimple
10
9
  } 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
13
  import { createOpenRouterOnPayload, OPENROUTER_ATTR_HEADERS, parseCanonicalModelRef, profileNames, resolveModelFromRef, ROUTER_TIERS } from './config';
@@ -95,7 +95,7 @@ export const registerRouterProvider = (
95
95
  const models = profileNames(currentConfig).map((name) => {
96
96
  const profile = currentConfig.profiles[name];
97
97
  let maxContextWindow = 0;
98
- let maxOutputTokens = 0;
98
+ let maxTokens = 0;
99
99
 
100
100
  if (state.currentModelRegistry) {
101
101
  for (const tier of ROUTER_TIERS) {
@@ -108,7 +108,7 @@ export const registerRouterProvider = (
108
108
  const currentContextWindow = model.contextWindow ?? 0;
109
109
  if (currentContextWindow > maxContextWindow) {
110
110
  maxContextWindow = currentContextWindow;
111
- maxOutputTokens = model.maxTokens ?? 120_000;
111
+ maxTokens = model.maxTokens ?? 120_000;
112
112
  }
113
113
  }
114
114
  }
@@ -116,7 +116,7 @@ export const registerRouterProvider = (
116
116
  }
117
117
 
118
118
  if (maxContextWindow === 0) maxContextWindow = 1_000_000;
119
- if (maxOutputTokens === 0) maxOutputTokens = 120_000;
119
+ if (maxTokens === 0) maxTokens = 120_000;
120
120
 
121
121
  const profileSupportsReasoning = state.currentModelRegistry
122
122
  ? ROUTER_TIERS.some((tier) => {
@@ -138,7 +138,7 @@ export const registerRouterProvider = (
138
138
  input: ['text', 'image'] as ('text' | 'image')[],
139
139
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
140
140
  contextWindow: maxContextWindow,
141
- maxTokens: maxOutputTokens,
141
+ maxTokens,
142
142
  };
143
143
  });
144
144
 
@@ -417,8 +417,8 @@ export const registerRouterProvider = (
417
417
 
418
418
  if (!success) {
419
419
  const errorMsg = `Failed to delegate to any model in the chain.${failureReasons.length > 0 ? ' Reasons: ' + failureReasons.filter(Boolean).join('; ') + '.' : ''}`;
420
- const combined = lastError ? new Error(`${(lastError as Error).message} — ${errorMsg}`) : new Error(errorMsg);
421
- throw combined;
420
+ const combinedError = lastError ? new Error(`${(lastError as Error).message} — ${errorMsg}`) : new Error(errorMsg);
421
+ throw combinedError;
422
422
  }
423
423
 
424
424
  stream.end();
@@ -1,4 +1,5 @@
1
- import { type Context, type Message, streamSimple } from '@earendil-works/pi-ai';
1
+ import { streamSimple } from '@earendil-works/pi-ai/compat';
2
+ import type { Context, Message } from '@earendil-works/pi-ai';
2
3
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
4
  import type { RouterConfig, RouterProfile, RouterTier, RoutingDecision } from './types';
4
5
  import { createOpenRouterOnPayload, OPENROUTER_ATTR_HEADERS, parseCanonicalModelRef, resolveModelFromRef } from './config';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kdejaeger/pi-model-router",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Intelligent per-turn model router extension for the pi coding agent",
6
6
  "keywords": [
@@ -41,10 +41,10 @@
41
41
  "prepublishOnly": "npm run tsc"
42
42
  },
43
43
  "peerDependencies": {
44
- "@earendil-works/pi-agent-core": "*",
45
- "@earendil-works/pi-ai": "*",
46
- "@earendil-works/pi-coding-agent": "*",
47
- "@earendil-works/pi-tui": "*",
44
+ "@earendil-works/pi-agent-core": "^0.80.6",
45
+ "@earendil-works/pi-ai": "^0.80.6",
46
+ "@earendil-works/pi-coding-agent": "^0.80.6",
47
+ "@earendil-works/pi-tui": "^0.80.6",
48
48
  "@sinclair/typebox": "*"
49
49
  },
50
50
  "devDependencies": {
package/LICENSE DELETED
@@ -1,21 +0,0 @@
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.