pi-extensions GitHub

pi-router — pick the model the task deserves

A virtual model. Select router/auto in /model, and pi asks the router for a physical model and thinking level before every request. It judges how hard the current task is, escalates to the mid or frontier tier when the rating clears a threshold, and returns to the base at the next task.

Why it looks like this

The shape is not taste, it is what measuring this repo's own 879-turn session corpus said. Three findings drove it:

Finding Number Consequence here
Cost is concentrated 6% of turns carried 77% of spend Worth routing at all
Prompt features cannot tell hard from easy frontier p50 61 chars vs mid 60 Rules are out; judgement is in
Sticky escalation without a way down +1665% spend Escalate per task, then release

And the two instruments TypeSafe could give:

Instrument AUC Verdict
difficulty (score 0–3) 0.70 what the router uses
needs_frontier (noul) 0.55 barely better than chance — rejected

At difficulty >= 1.5 the rating is 77% precise at 48% recall on the judged sample and catches 7 of the 12 most expensive turns in it, including both that a rules-based chooser confidently downgraded ("Right, one last chance. 4 different prototypes, nothing like the original site, go and impress me." at 2.18/3, and "Stick to the task yo" at 1.02/3). Dropping the threshold to 1.0 catches 12 of 12 at 65% precision, for roughly ten times the cost in wrong escalations.

That 1.5 line was measured for "escalate at all", not for the size of the step. The split between the mid tier and the frontier tier at 2.5 is a curated design choice that has not been calibrated. The default mid list leads with Astra@low, then Luna@xhigh, so mid is a real step above the explicit Luna@medium base pair at the usual caps. Treat both boundaries as dials.

An earlier version of this file claimed 11 of 12 at 1.5. That figure belonged to the needs_frontier question, which the router does not use — the audit that caught it is summarised at the bottom of this file.

Behaviour

Select router/auto in /model (or --model router/auto). Selecting a physical model instead deselects the router — ownership is a selection, not a state machine. The base is what the router holds at when it is not escalating: the first entry in the config's base list (Luna@medium by default, clamped by the cap through the fallback entry), defaulting to Luna.

Models

The model lists live in <agent-dir>/router.json (usually ~/.pi/agent/router.json), seeded with the built-in defaults on first load and re-read on every judgement — edit it while pi runs and the change takes effect at the next task, no restart:

{
  "base": [
    { "model": "openai-codex/gpt-5.6-luna", "thinking": "medium" },
    { "model": "openai-codex/gpt-5.6-luna" }
  ],
  "mid": [
    { "model": "openai-codex/gpt-6-astra", "thinking": "low" },
    { "model": "openai-codex/gpt-5.6-luna", "thinking": "xhigh" }
  ],
  "frontier": [
    { "model": "openai-codex/gpt-5.6-sol", "thinking": "max" },
    { "model": "openai-codex/gpt-5.6-sol", "thinking": "xhigh" },
    { "model": "openai-codex/gpt-6-astra", "thinking": "medium" },
    { "model": "openai-codex/gpt-6-astra", "thinking": "low" },
    { "model": "grok-4.6", "thinking": "medium" },
    { "model": "qwen3.8-max", "thinking": "medium" },
    { "model": "kimi-k3", "thinking": "medium" }
  ]
}

Qualify a pattern with its provider (openai-codex/gpt-6-astra) to pin which subscription pays for it. GPT models are restricted to openai-codex even when the pattern is unqualified — the same model is sold by several subscriptions, and the catalogue order decided it once: a live run escalated to github-copilot/gpt-6-astra. If no Codex GPT is available, GPT patterns are skipped rather than billed to a reseller, and the next pattern is tried. A pattern that names its provider is taken literally, and non-GPT patterns (grok-4.6, qwen3.8-max) are never restricted this way.

Configuration

Variable Default Meaning
PI_ROUTER unset (on) off / 0 / false skips judgements and holds the base for the process
PI_ROUTER_THRESHOLD 1.5 difficulty rating at which the router leaves the base (to mid or frontier)
PI_ROUTER_FRONTIER_THRESHOLD 2.5 rating at or above which the target is the frontier tier rather than mid (the two thresholds are ordered, so the lower starts mid and the higher starts frontier)
PI_ROUTER_TIMEOUT_MS 4000 judgement deadline, after which the model is left alone
PI_ROUTER_SHARE window prompt sends only the prompt to the judge, no conversation
PI_ROUTER_PLAYBOOKS unset (on) off stops the task-type judgement and playbook injection
PI_ROUTER_STUCK_BASELINE unset (on) off / 0 / false / no keeps the fixed two-failure stuck rule and stops updating router-turn-baseline.json

In-session: /route for status and counters, /route preview <prompt> for a dry run, /route preview --json <prompt> for the same report as JSON, and /route off and /route on. /route shadow on, /route shadow off, and /route shadow report control the shadow log (see below). off stops both routing and playbook judgments/sharing, cancels in-flight judgments and clears their cached answers/playbook. A result arriving after an off/on cycle is discarded too. It holds the base without changing the selected model. This switch covers this router, not other TypeSafe-enabled extensions. /route status prints sharing/playbook state, config path, the configured lists, and the effective ladder at the current thinking cap.

The ladder is what each tier resolves to under that cap, for example base Luna@medium · mid Astra@low · frontier Astra@medium. A tier that lands on the same model and the same thinking level as base is marked as collapsed, and a tier whose pairs all sit above the cap is marked as filtered out. At the low cap, mid and frontier can both land on Astra@low while base stays on Luna@low. The ladder only reports this. It does not change which model a turn gets.

/route preview asks Jev once, using the same difficulty state, rating and pair choice as a real turn. It prints the difficulty, the tier, the pair it would choose, whether that pair fits the current context, any cooled-down models it skipped, stale patterns, and the ladder. It does not switch models, change router state, or count as a turn. With no key, or if the call fails, it still prints what it can and says the difficulty is unknown. Preview is an explicit request, so it still makes that one call when routing is off.

Shadow log

Shadow mode is for comparing your chosen physical model to what router/auto would have picked, without switching models or touching session routing state. It is off by default.

Turn it on in <agent-dir>/router.json:

{ "shadowLog": true }

or in-session with /route shadow on (which also writes the flag into router.json). /route shadow off disables it again.

While shadow mode is on and you are not on router/auto, each user turn runs the same gate, difficulty judgement budget, and decideRoute path as live routing would — so the log stays aligned with production behaviour. Shadow mode makes the same judgement calls routing would on those turns; it respects the 3-per-task cap and failure cooldown. With router/auto selected, normal routing runs and nothing is written to the shadow file.

Append-only JSONL lives at <agent-dir>/router-shadow.jsonl (typically ~/.pi/agent/router-shadow.jsonl). Each line records a timestamp, session id, the model and thinking level actually in use, the would-be tier and pair at your current cap, the difficulty score (or null), the gate reason, the decision reason, and a short hash of the prompt for de-duplication. No prompt text and no conversation are stored. Delete the file to reset; it is never sent over the network.

/route shadow report prints turn counts, the share that would have stayed on base vs mid vs frontier, and how often the would-be pair differed from the actual one. The same summary is available offline:

node scripts/router-shadow-report.mjs
node scripts/router-shadow-report.mjs /path/to/router-shadow.jsonl

Playbooks

Skills only help when the model thinks to search for them. Playbooks don't wait for that: at each new task (the same boundary gate the router uses), one TypeSafe call asks what kind of work it is: bug, feature, refactor, investigation, autonomous or none. The matching file in playbooks/ (six numbered steps) is appended to the system prompt for every run of that task, and the footer shows playbook: <type>.

Unmeasured: whether the task-type answer is accurate on real prompts, and whether a playbook changes outcomes. Both need a pass over the session corpus before the types or the threshold are tuned.

Knowing the threshold is set right

The judgement is a rating, not a verdict, so the threshold is a dial between precision and recall. Two scripts measure it against this machine's own history:

node scripts/model-report.mjs       # what the deterministic rules would have done
node scripts/difficulty-report.mjs  # the judged rating, against real spend, with dollar costs

difficulty-report.mjs caches its judgements, so re-tuning costs nothing. Committed evidence at the time of writing: difficulty >= 1.5 → 77% precision, 48% recall, $23 of wrong escalations against $34 of frontier spend left on cheaper models. Lower the threshold, find more, pay more.

That evidence covers only the line at which the router leaves the current model. It says nothing about where mid ends and frontier begins: the 2.5 split and the pair order are curated design choices, not measurements. The claim that Sol@xhigh or Sol@max should beat Astra@low is represented by the editable order in router.json, not asserted by the router's measurement code.

What leaves your machine

Routing is a data-flow decision as much as a model one, so it is worth being explicit: the judgement sends the prompt, the working directory, and (by default) the last four turns — including assistant replies — to TypeSafe. An escalation then serves that conversation from a different provider than the one you were using. PI_ROUTER_SHARE=prompt omits recent conversation from both routing and playbook judgments (the current prompt and cwd still leave the machine). /route off or PI_ROUTER=off stops both judgment paths and their sharing. Provider escalation is avoided by staying off or not escalating; other extensions and the selected physical provider retain their own data flows.

What it deliberately does not do

Limits worth knowing

Tests

node --test packages/router/test/*.test.mjs — cases across the package cover the decision helpers and what was measured, pair-cap selection, the config file's merge semantics, threshold routing, model resolution and scoping, the session and metrics arithmetic, usage-cap recognition and the effective ladder, and end-to-end cases against a fake pi that drive the real route() over escalation, holding, step-down, retries, usage-limit failover, preview, shadow logging, children, capacity, live reload, and the kill switch.

What an audit of this extension found

The router once audited itself on a frontier model, and it was worth the $4: it found real defects. Fixed, each with a test:

Finding Was Now
The judgement budget never reset Three judgements early in a session made every later task unroutable A task boundary resets it
Step-down ignored the task boundary A stuck, failing task could be downgraded mid-failure Stepping down requires a new task
The router assumed ownership of any model Start A → user picks B → router escalates → easy task restored A model_select ends ownership; the baseline is captured immediately before switching
PI_SUBAGENT_CHILD was read per prompt pi-subagents clears it before the child's first prompt, so children were routed Captured at factory time
A decision could land after /route off A stale judgement still switched models Re-checked after the await
Failed judgements were free and unlimited A dead judge was retried every turn Counted as attempts, with a cooldown
The score was coerced, not validated true → 1, [2] → 2, 5 → 5 A finite number inside the scale, or no decision
Capacity was checked after selection The best model was picked, then refused, while a fitting one existed Candidates are filtered by capacity first, with headroom and output reserve
difficulty-report.mjs judged empty state An extraction refactor left a positional call where an object was expected Object call, and the builder now throws on the wrong shape

Most of that ownership machinery no longer exists. The virtual-model port replaced it with pi's own selection/dispatch split: a manually picked model is a deselection, the baseline is configuration, and a decision that changes nothing mid-flight is just a held continuation. What replaced the state machine is router state on the session branch, which also follows forks and survives compaction — things the module-level version could not do.

Still open, and written down rather than fixed: difficulty is a probability-weighted mean, so two very different distributions can share a 1.5; uncertainty and confidence are discarded; the difficulty scale mixes reasoning complexity with the cost of being wrong; a 2,000-character prompt truncation can hide the real requirement; and a rating is not a prompt-injection boundary. The router now sees every request, including steering and retries. A provider-outage retry stays on the model that failed. A usage-limit error does not: that retry moves to the next pair in the tier, and the cap is remembered for the next turn.

Metrics

node scripts/router-report.mjs reads the session transcripts plus the router's own decision entries and reports what the routing is costing and how it is landing. Everything in it is arithmetic over recorded facts; estimates say so.

Measured over 142 sessions, at the time of writing:

Measure Value
Model switches followed by a request 35 (22 more were selected and never used)
Cold tokens paid at a switch (p50) 38k, at p50 $0.0075
Requests until the destination is warm again p50 1
Frontier episodes 17 covering 1,086 requests, $154.87
Cost per frontier request vs the same sessions' cheaper model $0.0365 vs $0.0041
Estimated extra cost of those episodes $82.85 (same tokens at the cheap rate; quality not compared)
Cache share inside episodes 79% (corpus-wide: 98.6%)

Two things this settled by measuring rather than arguing:

Each decision also records a router-decision custom entry (which does not enter LLM context) with the rating, threshold, latency and outcome. That gives the calibration loop the report prints: a held judgement followed by a manual escalation is a miss, an escalated one followed by a manual retreat is needless. Both are counted per rating band, so the threshold can be tuned from live use rather than from the enriched historical sample. It needs a few dozen judgements before the bands mean anything.