omp packages

pi-jev-model-router0.8.0

Route pi prompts to task-appropriate model tiers with TypeSafe Jev typed judgments. Budget-aware, with automatic fallback.

da-vinci-noob Published 16 days ago MIT ~1.9K downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-jev-model-router

A pi extension that routes every prompt to a task-appropriate model using TypeSafe Jev (System One) typed judgments. You type normally; before the turn starts, Jev reads the request and answers four narrow questions, code composes those into a capability tier, applies your budget policy, and pi switches to the matching model.

Routing decision shown in the transcript

Every routed prompt shows the model it chose and the judgment behind it.

  • Task-aware — planning goes to reasoners, implementation to coding specialists, chat to cheap fast models.
  • Budget-aware — daily/monthly caps downgrade tiers automatically instead of overspending.
  • Resilient — each tier is a candidate chain; if a model is unavailable or unauthenticated, the next one is used.
  • Visible — the transcript records the chosen model and the exact reason (kind, complexity, capability, reasoning, budget pressure).
  • Fails open by default — a missing key, timeout, or unknown model warns and keeps the current model; opt-in quota protection can stop submission to an ineligible model.

How it works

you type a prompt
        │
        ▼
   Jev (one request, 4 parallel questions)
     • task_kind            choice: plan / implement / debug / refactor / review / research / explain / operate / write / chat
     • complexity           score:  trivial → architectural
     • capability_deserved  score:  minimal → maximum (price ignored)
     • needs_deep_reasoning noul:   yes/no probability
        │
        ▼
   code composes the decision
     demand = 0.55·complexity + 0.45·capability (+ reasoning nudge)
     demand = max(demand, kind floor)          # planning/review never go cheap
     confidence guard → budget guard → availability/quota guard → cache guard
        │
        ▼
   pi.setModel(...) + pi.setThinkingLevel(...)  → the turn runs on that model

Jev judges the task, code owns the budget. Changing your spend caps never invalidates the judgment, and the judgment stays a pure semantic read of the request.

Requirements

  • pi (@earendil-works/pi-coding-agent)
  • Node.js 20+
  • A TypeSafe API key with access to jev-latest — https://typesafe.ai

Install

1. Install pi

npm install -g --ignore-scripts @earendil-works/pi-coding-agent

Verify with pi --version. See the pi quickstart for other installation methods and authentication options.

2. Install this package

From npm (recommended):

pi install npm:pi-jev-model-router

From a pinned git ref:

pi install git:github.com/da-vinci-noob/pi-jev-model-router@v0.8.0

From a local checkout:

pi install /absolute/path/to/pi-jev-model-router

Try it once without installing:

pi -e npm:pi-jev-model-router

Manage it like any other pi package:

pi list                       # show installed packages
pi update --extensions        # update packages
pi remove npm:pi-jev-model-router

3. Set your TypeSafe API key

export TYPESAFE_API_KEY=...

Add it to your shell profile to persist it. You can also set apiKey directly in the config file (see below).

4. Use it

Start pi and type a request. Before the turn runs, the router announces the decision:

jev-router → high  openai/gpt-5.3-codex
implement · complexity 1.70/3 · capability 1.55/3 · reasoning 0.82 → high

The status bar shows jev-router:<tier> · $spend · %cap, or jev-router:off when disabled. No configuration is required — sensible defaults are built in.

Commands

Command What it does
/jev-router Status: mode, spend, tier chains, kind specialists, last decision
/jev-router on / off Enable/disable routing
/jev-router mode auto|confirm|notify auto switches silently; confirm asks each turn; notify only tells you
/jev-router budget daily 10 Session-only daily cap (persist it in the config file)
/jev-router budget monthly 150 Session-only monthly cap
/jev-router why Re-run Jev on the last prompt and show the full judgment + decision trace
/jev-router revert Switch back to the model that was active before the last auto-switch
/jev-router suggest Rank your scored models into proposed routes / kindModels and print them (see Ranking models from your own scores)
/jev-router suggest --write Same, and save it as a generated config layer that your own config overrides
/jev-route <text> Classify arbitrary text and show the recommendation without switching

The model can also call the jev_route tool to ask for a tier recommendation for a subtask.

What you see

Every decision is a durable entry in the transcript, so the chosen model and its justification are always available:

jev-router → high  openai/gpt-5.3-codex
implement · complexity 1.70/3 · capability 1.55/3 · reasoning 0.82 → high
· budget 12% of cap → one tier down

The glyph encodes the action: → switched, = already active (stickiness), • notify-only mode, × skipped. Expand the entry for the raw judgment: kind and confidence, complexity, capability deserved, deep-reasoning probability, composed demand, and budget pressure. Entries are stored in the session but never sent to the LLM, so they cost no context.

Prompts that are deliberately not routed are shown too, so behaviour is never silently missing:

jev-router · not routed
acknowledgement — staying on the current model
using openrouter/~anthropic/claude-opus-latest

Which models it uses by default

The defaults target OpenRouter, because it exposes a large catalogue through a single provider id. Four capability tiers, each an ordered fallback chain:

Tier Order tried
quick ~google/gemini-flash-latest → ~openai/gpt-luna-latest → ~z-ai/glm-flash-latest → ~deepseek/deepseek-v4-flash-latest
standard ~deepseek/deepseek-pro-latest → openai/gpt-5.4-mini → ~z-ai/glm-latest
high ~anthropic/claude-sonnet-latest → ~openai/gpt-terra-latest → ~google/gemini-pro-latest → ~x-ai/grok-latest
premium ~anthropic/claude-opus-latest → openai/gpt-5.5 → ~openai/gpt-astra-latest
xpremium empty: off until you configure it (see Extra-premium tier)

Plus kind specialists, tried before the tier chain when the chosen tier is high enough (minTier):

Kind Specialists
plan ~anthropic/claude-opus-latest (≥premium) → ~openai/gpt-astra-latest (≥premium) → ~openai/gpt-terra-latest (≥high) → ~google/gemini-pro-latest (≥standard)
implement openai/gpt-5.3-codex (≥standard) → moonshotai/kimi-k2.7-code → ~anthropic/claude-sonnet-latest
debug openai/gpt-5.3-codex (≥standard) → ~openai/gpt-terra-latest (≥high) → ~anthropic/claude-sonnet-latest
refactor openai/gpt-5.3-codex (≥standard) → moonshotai/kimi-k2.7-code
review ~anthropic/claude-opus-latest (≥high) → openai/gpt-5.5 (≥high) → ~anthropic/claude-sonnet-latest
research ~google/gemini-pro-latest (≥standard) → moonshotai/kimi-k3 → ~openai/gpt-terra-latest
explain ~google/gemini-flash-latest (≥quick) → openai/gpt-5.4-mini → ~google/gemini-pro-latest (≥standard)
operate openai/gpt-5.4-mini (≥standard) → ~deepseek/deepseek-pro-latest
chat ~google/gemini-flash-latest → ~openai/gpt-luna-latest → ~z-ai/glm-flash-latest
write ~google/gemini-flash-latest (≥quick) → openai/gpt-5.4-mini → ~anthropic/claude-sonnet-latest (≥standard)

Provider-maintained ~...-latest aliases are used wherever they exist, so the chains follow new model releases instead of going stale.

Run /jev-router to see this for your own setup, with a ✓/✗ per route showing what is actually available and authenticated.

Extending to more models and providers

The router is provider-agnostic: it only references models that pi already knows about, so if pi can use a model, the router can route to it. You are never limited to OpenRouter — the defaults are just a convenient starting point.

0. Drop the built-in models entirely (optional)

By default your config is merged over the built-in chains, so a tier you don't mention keeps its defaults. If you'd rather start from nothing and use only your own models, set:

{
  "useDefaultModels": false,
  "routes": {
    "quick": [{ "provider": "openrouter", "model": "~z-ai/glm-flash-latest" }],
    "high":  [{ "provider": "anthropic", "model": "claude-sonnet-4-5" }]
  },
  "kindModels": {
    "implement": [{ "provider": "openrouter", "model": "moonshotai/kimi-k2.7-code", "minTier": "standard" }]
  }
}

With useDefaultModels: false:

  • the built-in routes and kindModels are gone — not available as a base or as fallback;
  • tiers or kinds you don't configure are empty, and the router simply skips them (it never invents a model);
  • everything that isn't a model list still applies — endpoint, timeouts, budget, cache, and the kindMinimumTier floors.

/jev-router prints built-in models: off (config-only) and marks empty tiers as (none configured). If a tier you need is empty, pi warns on session start.

1. Find the model ids pi knows

pi --list-models                 # all providers
pi --list-models | grep anthropic
pi --list-models | grep -E 'gpt-5|codex'

The first column is the provider id and the second is the model id. Those are exactly the two fields the config uses.

Providers not yet configured can be added with pi /login, an API key environment variable, or a custom provider registered by another extension — including local servers such as Ollama or llama.cpp. See the providers docs.

2. Point the tiers at your models

Create ~/.pi/agent/pi-jev-model-router.json (or <project>/.pi/pi-jev-model-router.json). Anything you set is merged over the defaults, per tier.

{
  "routes": {
    "quick": [
      { "provider": "openrouter", "model": "~google/gemini-flash-latest" }
    ],
    "standard": [
      { "provider": "anthropic", "model": "claude-haiku-4-5" }
    ],
    "high": [
      { "provider": "anthropic", "model": "claude-sonnet-4-5" },
      { "provider": "openai", "model": "gpt-5.4" }
    ],
    "premium": [
      { "provider": "anthropic", "model": "claude-opus-4-5" },
      { "provider": "openai", "model": "gpt-5.5-pro" }
    ]
  }
}

Each tier is a candidate chain, tried top to bottom. The first model that exists in pi's catalogue and is authenticated wins; if none are usable the router steps to the nearest tier instead of failing. Add "thinkingLevel" to any entry to pin it ("off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; pi clamps it per model).

Mixing providers is fine — put an OpenRouter model and a direct-Anthropic model in the same chain.

3. Add or change task specialists

{
  "kindModels": {
    "implement": [
      { "provider": "openrouter", "model": "openai/gpt-5.3-codex", "minTier": "standard" },
      { "provider": "anthropic", "model": "claude-sonnet-4-5", "minTier": "standard" }
    ],
    "plan": [
      { "provider": "openai", "model": "gpt-5.5", "minTier": "high" },
      { "provider": "anthropic", "model": "claude-opus-4-5", "minTier": "premium" }
    ]
  }
}

minTier gates a model to a minimum capability tier, so a specialist is only used when the judgment justifies it. When priorities tie (the default), the eligible specialist whose minTier is closest to the chosen tier wins, so a cheap specialist does not win a premium-quality turn.

To rank a specialist up without changing when it is eligible, give it a numeric priority (default 0, higher first). priority is compared before minTier, so this keeps a free model eligible from quick and still tries it ahead of a paid high specialist:

{
  "kindModels": {
    "write": [
      { "provider": "opencode-go", "model": "space-bunny-free", "minTier": "quick", "priority": 1 },
      { "provider": "openrouter", "model": "~anthropic/claude-sonnet-latest", "minTier": "high" }
    ]
  }
}

Without any priority, ordering is exactly as before.

4. Set the floor per task kind

{
  "kindMinimumTier": {
    "plan": "high",
    "review": "high",
    "implement": "standard",
    "debug": "standard",
    "chat": "quick"
  }
}

Known kinds: plan, implement, debug, refactor, review, research, explain, operate, write, chat.

5. Add or change task kinds

Jev picks one kind per prompt from taskKinds: a label and a one-line description that Jev uses as the choice criteria. Add your own domains or reword a built-in description in config, then add matching kindModels and kindMinimumTier entries:

{
  "taskKinds": {
    "data": "Querying, cleaning, or transforming datasets and SQL",
    "infra": "Provisioning or changing cloud infrastructure and deploy config"
  },
  "kindModels": {
    "data": [{ "provider": "openrouter", "model": "~google/gemini-pro-latest", "minTier": "standard" }]
  },
  "kindMinimumTier": { "infra": "high" }
}

Entries merge over the built-in kinds (global, then project). A kind can't be removed, and entries without a non-empty string description are ignored. Because the question is a Jev choice, the option set is the taxonomy: no retraining, no prompt parsing.

Config resolution order

Later sources win:

  1. built-in defaults
  2. ~/.pi/agent/pi-jev-model-router.generated.json: only routes and kindModels, written by /jev-router suggest --write
  3. ~/.pi/agent/pi-jev-model-router.json
  4. <cwd>/.pi/pi-jev-model-router.json (trusted projects only)
  5. env: TYPESAFE_API_KEY, JEV_ROUTER_MODE (auto|confirm|notify), JEV_ROUTER_OFF=1

A full example lives at extensions/pi-jev-model-router/pi-jev-model-router.example.json. Run /reload after editing config.

Budget management

Cost is accumulated from each assistant message's computed cost into ~/.pi/agent/pi-jev-model-router-state.json, together with Jev request counts.

pressure = max(today ÷ dailyUsd, month ÷ monthlyUsd)
  • pressure ≥ softRatio (default 0.7) → drop one tier
  • pressure ≥ hardRatio (default 0.9) → force quick, unless the demand score is ≥ 2.5 (clearly architectural), which may stay at standard
{
  "budget": {
    "dailyUsd": 5,
    "monthlyUsd": 100,
    "softRatio": 0.7,
    "hardRatio": 0.9
  }
}

Omit either cap to disable that dimension. Caps are policy, not a hard stop — they redirect routing, they do not block turns.

Prompt-cache awareness

Switching models discards the provider's prompt cache, and caches are per-model. The next request then re-reads the entire prefix — system prompt, tool schemas, and conversation — at the new model's full input rate. Cache reads are ~10% of input on the major providers, so a switch effectively costs the whole context once, and a switch back costs it again. On a 50k context that is roughly $0.10 on Sonnet; at 200k, roughly $0.45.

The router therefore gates switches instead of making them freely:

  • Cache penalty cap — estimates the miss (contextTokens × new model's input rate, minus the cached rate) and refuses the switch when it exceeds maxPenaltyUsd.
  • Dead-band — demand has to clear the current tier's band (tier ± 0.5) by deadband before a tier change is considered, so prompts hovering on a boundary stop flapping between two models.
  • Big-jump bypass — a tier jump of bypassTierDelta or more still switches, because that is a genuine capability change rather than a marginal one.
  • Same-tier swaps count too — a specialist swap such as Sonnet → Codex at the same tier is still a model change, and is priced the same way.

Held turns still record the decision, and say so:

jev-router = high  openrouter/~anthropic/claude-sonnet-latest
explain · complexity 0.40/3 · capability 0.30/3 · reasoning 0.20 → standard, held on high to keep the cache
· cache penalty ~$0.186 on 120k tokens — keeping the warm cache

The estimate is a lower bound — real cacheable prefixes include the system prompt and tool schemas, which contextTokens does not count — and it is skipped entirely when a model's pricing is unknown, so it never blocks on guesses. Set cache.aware: false to restore unconditional switching.

{
  "cache": {
    "aware": true,
    "deadband": 0.25,
    "maxPenaltyUsd": 0.05,
    "bypassTierDelta": 2
  }
}

Ranking models from your own scores

Instead of maintaining routes by hand, you can give the router a scores file and let it propose chains from the models pi actually has. The router never fetches rankings itself; any script or board export that writes this JSON works.

~/.pi/agent/pi-jev-model-router.scores.json:

{
  "models": {
    "openrouter/~anthropic/claude-opus-latest": { "score": 0.92, "kinds": { "review": 0.95, "plan": 0.9 } },
    "openrouter/~google/gemini-flash-latest": { "score": 0.55, "kinds": { "write": 0.7 } },
    "grok-cli/grok-4.6": { "score": 0.8, "cost": { "input": 0, "output": 0 } }
  }
}
  • Keys are <provider>/<model id> and must match a model in pi's catalogue exactly. Scored models pi doesn't have are listed as not in the catalogue.
  • score places the model on a tier by fixed cut-offs (inclusive), below standard is quick. xpremium is never filled; set it by hand. Scores and cut-offs just need the same scale (0-1 by default, or e.g. 0-100 with matching cut-offs), and cut-offs that are not ascending are ignored.
  • Within a tier, models are ordered by score per cost, using pi's catalogue price (input + output USD per 1M tokens) unless the entry has a cost override in the same units. Use the override for flat subscriptions or quota limits: set your own effective price, or 0 to treat the model as free (free models rank first). Models without any known price go last.
  • kinds scores build kindModels for kinds in taskKinds, each gated (minTier) at the tier its kind score clears.
  • With spreadProviders on (the default), a chain never has two entries in a row from the same provider when it can be avoided.
{
  "ranking": {
    "cutoffs": { "standard": 0.5, "high": 0.7, "premium": 0.85 },
    "spreadProviders": true,
    "scoresFile": "~/.pi/agent/pi-jev-model-router.scores.json"
  }
}

/jev-router suggest prints the proposal. /jev-router suggest --write saves it to ~/.pi/agent/pi-jev-model-router.generated.json and applies it right away. If none of the scored models match pi's catalogue, --write warns and leaves the existing generated file untouched.

That file is loaded before your own config, and your config file is never written. So anything you set by hand wins over the generated file:

  • a tier or kind you define replaces the generated one
  • an explicit empty list clears it, e.g. "routes": { "high": [] } or "kindModels": { "plan": [] } (a list with only invalid entries is ignored)

Generated specialists carry a descending priority, so routing tries them in the proposed order. The built-in chains still fill any tier or kind the generated file doesn't cover. To route only from your scores and hand edits, set "useDefaultModels": false.

Extra-premium tier

xpremium sits above premium for the most expensive model class (around $50 / 1M output tokens). It is off until you give it a chain, and the built-in models never fill it:

{
  "routes": {
    "xpremium": [{ "provider": "openrouter", "model": "~openai/gpt-astra-latest", "thinkingLevel": "high" }]
  }
}

The demand scale is unchanged, so no prompt that routes to premium today moves up on its own. A turn is promoted to xpremium only when all of these hold:

  • demand already lands on premium
  • Jev's kind confidence is known and at least confidenceThreshold
  • the budget guards don't step it down (soft pressure drops it back to premium)

Otherwise the xpremium chain is never tried, not even as a fallback when premium is unavailable. kindMinimumTier: "xpremium" is treated as premium. A kind specialist with minTier: "xpremium" only serves promoted turns.

Confirming an expensive switch

Jev occasionally reads a medium turn as premium or xpremium. confirm.tiers makes those switches ask first, so one wrong judgment does not silently spend an expensive model:

{
  "confirm": {
    "tiers": ["premium", "xpremium"],
    "timeoutMs": 10000,
    "onTimeout": "accept"
  }
}

tiers defaults to ["xpremium"], which is inert until you give routes.xpremium a chain, so nothing changes for a config that never enables that tier. Add "premium" if that is where you want a hand on the switch. Any other tier switches without asking.

When the gate opens, the answer accepts a tier directly:

Answer Effect
Y or Enter switch to the suggested tier
N keep the current model
0 force the free pool, keeping the current model when the pool is disabled or empty
1 to 5 jump to quick, standard, high, premium, xpremium

The digits map onto TIERS in order, so 1 is quick and 5 is xpremium. Anything unrecognised keeps the current model, so a mistyped key never spends an expensive model.

timeoutMs is passed to the dialog, which shows a countdown and dismisses itself. 0 waits for an answer instead. On a dismiss or a timeout the router applies onTimeout: accept switches anyway, reject keeps the current model. The default accept means a turn is never lost to a dialog the user did not see.

This is independent of mode. confirm mode asks about every switch, the gate asks only about the tiers you list, and notify never asks because it never switches. A guarded tier asks once, not once per layer.

A tier jump records a rerouted decision, so /jev-router why shows the tier Jev judged alongside the model that actually served the turn.

Codex quota guards

Subscription quota is independent of dollar spend. An opt-in Codex guard skips models whose remaining account quota is below a configured floor, then walks the existing fallback chains:

{
  "quota": {
    "openai-codex": {
      "cacheTtlSec": 120,
      "onUnknown": "use",
      "minQuota": { "fiveHour": 0.05, "weekly": 0.05 }
    }
  },
  "routes": {
    "premium": [
      {
        "provider": "openai-codex",
        "model": "gpt-5.4",
        "minQuota": { "weekly": 0.20 }
      },
      {
        "provider": "openai-codex",
        "model": "gpt-5.3-codex",
        "minQuota": { "fiveHour": 0.05 }
      },
      { "provider": "openrouter", "model": "openai/gpt-5.4-mini" }
    ]
  }
}

Ratios are remaining, not used: 0.20 means 20% must remain. Equality passes. Provider floors apply to every resolved Codex model; target floors in routes, kindModels, or free.pool can only make them stricter. Codex quota is one account pool, not a separate allowance for each model. Only fiveHour and weekly are supported in this first version; other providers are unchanged.

Adding the quota.openai-codex object enables the reader (enabled: false disables it). Without that object, no quota requests or gates run. Floors on individual targets are inert until the reader is enabled. cacheTtlSec defaults to 120 (valid range 1–86400 seconds); onUnknown defaults to use.

The reader uses Pi's resolved openai-codex credentials and the fixed endpoint https://chatgpt.com/backend-api/wham/usage. You do not need to copy tokens into config. It refreshes in the background from session_start, with a five-second refresh deadline, and stops on session_shutdown. Routing never waits for a quota HTTP request. Readings are in memory only; authentication errors and HTTP response bodies are not logged. The endpoint is provider-specific and may change.

Missing windows, expired readings, and readings whose reset time has passed are unknown. use admits those models with a visible note; skip rejects them. Startup can be unknown until the first background fetch completes. A failed refresh does not extend the old reading's freshness. The reader refreshes at the earlier of the TTL or the next reported reset; a nearby reset never waives a floor or assumes the account is full.

Quota checks also apply to cache retention, confirmation overrides, and /jev-router revert. Rejections appear in decision notes and /jev-router why, for example gpt-5.4 skipped: codex weekly 17.0% < 20.0%.

If no eligible route exists and the current model is also quota-ineligible, the router explicitly reports prompt not sent instead of submitting it to that model. The same protection applies if a confirmation keeps an ineligible current model, or routing fails. Choose another model or retry after a refresh; blocked prompts are not queued automatically. notify mode still never switches, but can block submission to a quota-ineligible current model. /jev-router off disables routing and its submission protection. Extension-generated input remains outside the routing hook.

This reduces the risk of mid-flight cancellation; it cannot reserve quota or predict how much a long-running turn will consume. Model-scoped quotas, generic HTTP adapters, and turn-dependent floors are deliberately deferred.

Free models

Tiers describe capability, not price, so there is nowhere in TIERS to put a model that costs nothing. A free pool is consulted outside the tier scale instead, so the judgment is untouched and a free model never has to pretend to be a tier.

{
  "free": {
    "enabled": true,
    "policy": "prefer",
    "pool": [
      { "provider": "opencode-go", "model": "space-bunny-free", "thinkingLevel": "medium" },
      { "provider": "opencode-go", "model": "longcat-2.5-preview-free", "thinkingLevel": "medium" }
    ]
  }
}
  • policy: "prefer" tries the pool first, then the kind chains and tier chains.
  • policy: "fallback-only" tries the pool last, after every tier chain.
  • enabled: false (the default) ignores the pool entirely, so an existing config routes exactly as before.

Pool entries match on provider and model exactly, so a missing free model never falls back to a paid model with the same ID. The first pool entry in the model catalogue wins; one that is missing falls through to the normal chains. When a pool model wins, the decision still reports the tier Jev judged, plus a free pool → <model> note, so the transcript shows why the tier and the model differ.

A pool model is not on any tier, so tierForModel reports no tier for it and the status line falls back to jev-router:on. That is deliberate: a free model has no capability tier.

Free models are usually temporary. If a provider withdraws one from the catalogue, routing skips it and continues down the chains, so a stale pool entry costs nothing but a wasted slot. Only the chosen model is tried: if switching to it fails (for example, no auth for the provider), the turn stays on the current model.

Free and preview models may log or train on prompts, and prefer sends every turn to the pool. Check the provider's data policy before enabling it.

Configuration reference

Key Default Purpose
enabled true Master switch
useDefaultModels true false drops the built-in model chains so only your config's models are used
mode "auto" auto | confirm | notify
apiKeyEnv / apiKey TYPESAFE_API_KEY TypeSafe credentials
endpointEnv / endpoint TYPESAFE_API_URL / https://api.typesafe.ai/v1/systemone TypeSafe-compatible evaluation endpoint (non-empty env wins over endpoint)
jevModel "jev-latest" Jev model alias
timeoutMs 3500 Jev request timeout (retries 429/529)
minPromptChars 12 Below this, a prompt counts as a continuation (a short first message is still routed)
historyTurns 4 Conversation turns included as Jev state
confidenceThreshold 0.34 Below this, fall back to standard instead of spending premium
stickiness true Keep the current model when it is already the chosen one
routes see above Capability tier candidate chains; xpremium is empty (off) unless you set it
kindModels see above Task-specialist chains with minTier (gate) and optional priority (rank)
kindMinimumTier see above Per-kind floor tier
taskKinds the 10 kinds above Kind labels and descriptions Jev chooses from; merged over the built-ins
ranking cut-offs 0.5 / 0.7 / 0.85, spreadProviders: true Scores file and cut-offs for /jev-router suggest
free disabled Free-model pool consulted outside the tier scale (prefer or fallback-only)
confirm tiers: ["xpremium"], timeoutMs: 10000, onTimeout: "accept" Ask before switching into these tiers; 0 forces the free pool, 1 to 5 jump to a tier
quota disabled Cached Codex 5h/weekly remaining-quota floors, independent of spend
budget no caps Spend policy
cache aware, cap $0.05, deadband 0.25 Prompt-cache-aware switching
stateFile ~/.pi/agent/pi-jev-model-router-state.json Spend ledger

Failure behaviour

By default, routing failures do not block your turn. A missing key, network error, timeout (default 3.5 s, retried on 429/529), or unknown model warns and runs the prompt on the current model unchanged.

Opt-in quota protection is the exception: if routing cannot switch safely and the current model is quota-ineligible, the router reports prompt not sent. Such prompts are not automatically queued.

Prompts starting with / and messages sent by other extensions are not routed. Acknowledgements (yes, continue, …) and short continuations normally stay on the current model, but are routed when quota protection rejects that model.

Publishing to pi.dev/packages

The pi package gallery is built from npm: it lists packages that are tagged with the pi-package keyword. There is no separate submission form — publishing to npm is the submission.

This repository is already prepared for it:

  • package.json contains "keywords": ["pi-package", ...]
  • package.json contains a pi manifest pointing at the extension entry point
  • package.json contains pi.image, which the gallery uses as the preview card

To publish:

# 1. Log in to npm (once)
npm login

# 2. Sanity-check what will be shipped
npm pack --dry-run

# 3. Publish
npm publish --access public

Then:

  1. The gallery indexes it on its next crawl (usually minutes; allow a few hours).
  2. Check the listing at https://pi.dev/packages/pi-jev-model-router.
  3. Anyone can then install it with pi install npm:pi-jev-model-router.

Releases: bump version in package.json, commit, tag, and re-run npm publish. Keep the image URL pointed at a released tag or main so it never 404s.

If the name is taken, publish under a scope (@yourname/pi-jev-model-router) — the gallery indexes scoped packages too; install with pi install npm:@yourname/pi-jev-model-router.

Git-only distribution also works (pi install git:github.com/user/repo@v1), but only npm packages appear in the gallery.

You can also add a GIF or MP4 demo via pi.video (MP4 only, takes precedence over image).

Development

git clone https://github.com/da-vinci-noob/pi-jev-model-router
cd pi-jev-model-router

# load the package into a throwaway pi run (ignores auto-discovered extensions)
pi -ne -e "$PWD" -p "Explain what an idempotency key does."

# or copy into the auto-discovered location for hot reload
cp -R extensions/pi-jev-model-router ~/.pi/agent/extensions/

Tests and typecheck (the same checks CI runs on every PR):

bun install --frozen-lockfile
bun run typecheck   # tsc against the real pi ExtensionAPI types
bun test            # policy, config, HTTP clients, ranking, and extension integration tests

Tests stub fetch, so they never hit the network. Tests that load config mock os.homedir to a temp dir, so your real ~/.pi config doesn't affect them.

Layout:

File Role
extensions/pi-jev-model-router/index.ts pi wiring: events, commands, jev_route tool, model switching, transcript entries
extensions/pi-jev-model-router/config.ts config types, defaults, layered loading, task taxonomy
extensions/pi-jev-model-router/jev.ts TypeSafe HTTP client, question definitions, response parsing
extensions/pi-jev-model-router/router.ts composition (decide), tier/kind chains, availability fallback
extensions/pi-jev-model-router/budget.ts spend ledger, caps, pressure
extensions/pi-jev-model-router/ranking.ts scores file, tier cut-offs, provider spread for /jev-router suggest
extensions/pi-jev-model-router/quota.ts Codex quota readings, eligibility checks, session-owned background cache

No runtime dependencies: the extension talks to TypeSafe with plain fetch. It imports typebox (tool schema) and @earendil-works/pi-coding-agent (config directory path), and loads @earendil-works/pi-tui lazily, only when the host implements registerEntryRenderer. @earendil-works/pi-tui is declared as an optional peer dependency, so hosts that don't ship it still install and run.

Contributing

  • Work on a feature/fix branch and open a PR targeting main; do not push directly to main.
  • Use TDD for behavior changes: write a failing test, implement the fix, then refactor with tests green.
  • Add regression coverage and update docs when behavior or configuration changes.
  • Before pushing, run bun run typecheck, bun test, and git diff --check; include results in the PR.
  • Keep tests offline with mocked HTTP and temporary state. See AGENTS.md for the repo map and development constraints.

Compatibility with pi builds and forks

ExtensionAPI surfaces differ across pi versions and downstream forks (for example omp). The extension probes the host at load time and degrades instead of failing installation:

Capability If the host lacks it
registerEntryRenderer or @earendil-works/pi-tui No transcript card; decisions still show in the status bar and notifications
appendEntry Decisions are not persisted as session entries
ctx.ui.notify / ctx.ui.setStatus Silently skipped
ctx.ui.select confirm mode falls back to auto-switching
ctx.ui.input The confirm.tiers gate falls back to auto-switching
ctx.modelRegistry.find / getAvailable Reports "model not available in this build" and leaves the current model in place
ctx.modelRegistry.getApiKeyForProvider Codex quota is unknown; applies quota.openai-codex.onUnknown
registerCommand / registerTool Commands and the tool are not registered; event-driven routing still works

Nothing in the extension throws during load if an optional API is missing, so pi install, omp install, or any plugin validator will accept it.

License

MIT