@timmyjl12/pi-fallback-chains
Glob-keyed model fallback chains for pi. Per-model failover that walks a real ordered chain across providers, instead of one flat per-provider list.
Package details
Install @timmyjl12/pi-fallback-chains from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@timmyjl12/pi-fallback-chains- Package
@timmyjl12/pi-fallback-chains- Version
0.4.0- Published
- Nov 8, 2026
- Downloads
- 290/mo · 28/wk
- Author
- timmyjl12
- License
- MIT
- Types
- extension
- Size
- 167.4 KB
- Dependencies
- 0 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@timmyjl12/pi-fallback-chains
Glob-keyed model fallback chains for pi.
When a provider fails, most fallback tools can only say "try the next provider". This one lets you say
which model falls back to which models, in order, across providers — the shape
oh-my-pi's retry.fallbackChains uses.
{
"chains": {
"cline-pass/cline-pass/glm-5.3-flash": [
"cline-pass-gmail/cline-pass/glm-5.3-flash",
{ "model": "neuralwatt/glm-5.3-flash-flex", "thinking_level": "max" },
"opencode-go/glm-5.3-flash",
"openrouterTim/openrouter/fusion:xxs"
]
}
}
Why not the alternatives
| Capability | pi core retry.* |
pi-provider-fallback |
this |
|---|---|---|---|
| Retries a failed request | yes | yes | yes |
| Switches model/provider | no | yes | yes |
| Per-model chains | no | no (per-provider) | yes |
| Glob-keyed chain keys | no | no | yes |
| Ordered cross-provider chain | no | no (sorted by availability) | yes |
| Per-target thinking level | no | no | yes |
| Configurable error patterns | no | no (hardcoded regexes) | yes |
| Per-request failover budget | n/a | no (per-session pointer) | yes |
| Reports inert targets | n/a | partial | yes |
pi core's models.json also has a per-model allowedFallbackModels, but it caps at three entries and
is server-side — the provider has to honour it. This extension is client-side, unlimited in length,
and works with any provider.
Install
pi install npm:@timmyjl12/pi-fallback-chains # install
pi -e npm:@timmyjl12/pi-fallback-chains # try for one run without installing
Configure
Config is read from the first path that exists, in this order:
$PI_FALLBACK_CHAINS_CONFIG— used outright. If set and missing, that is reported as a misconfiguration rather than silently falling through to the paths below.<project>/.pi/pi-fallback-chains.json~/.pi/agent/pi-fallback-chains.json
A project file therefore overrides your global one, and a global file is found even when the project has
none. /fallback-chains lists every path that was probed.
Configuration is optional: with no file, recoverable provider failures get one final attempt on
openrouterTim/openrouter/fusion:xxs. Set "terminalFallback": false to disable that tier, or
"enabled": false to disable all recovery. An explicitly missing override, unreadable/malformed
file, or explicitly unusable chain stays a configuration error; it does not silently enable Fusion.
Start from pi-fallback-chains.example.json.
Chain keys are globs over provider/modelId
Patterns are anchored at both ends and matched against the full provider/modelId string. *
matches any run of characters including /, which is required because model ids are themselves
namespaced:
cline-pass/cline-pass/glm-5.3-flash
└─ provider ┘└─── model id ───────┘
That gives the property you want — overlapping provider names stay separate:
| Pattern | cline-pass/cline-pass/glm-5.3-flash |
cline-pass-gmail/cline-pass/glm-5.3-flash |
|---|---|---|
cline-pass/* |
✅ | ❌ |
cline-pass-gmail/* |
❌ | ✅ |
* |
✅ | ✅ |
Also supported: ? for exactly one character. Everything else is literal (a . is a dot, not a
wildcard).
Which chain wins
- An exact key with no wildcards.
- Otherwise the matching key with the most literal characters.
- Otherwise
defaultChain, if set. - Otherwise the terminal Fusion tier alone is available.
Configured non-Fusion candidates always precede Fusion. The original remaining candidates are retained when a replacement fails; that replacement's own configured chain is appended behind them (with deduplication), preserving cascading routes without losing the original queue. Fusion is removed from ordinary targets, even glob matches, and offered exactly once at the end. Its attempt is reserved beyond the ordinary budget. A failure on Fusion is terminal, including when Fusion was the initial model. Unavailable, out-of-scope, or quota-capped Fusion is reported, never forced.
So you can have a broad cline-pass/* chain plus a sharper
cline-pass/cline-pass/kimi-k3 chain, and only kimi-k3 takes the sharper path.
Targets, and thinking effort
A target is either a glob or an exact model key. Thinking effort can be expressed three ways — all equivalent:
{ "chains": {
"a/*": [ "neuralwatt/kimi-k3-flex:max" ],
"b/*": [ { "model": "neuralwatt/kimi-k3-flex", "thinking_level": "max" } ],
"c/*": { "thinking_level": "max", "targets": ["neuralwatt/kimi-k3-flex"] }
} }
:levelsuffix —:off|minimal|low|medium|high|xhigh|max.thinking_levelfield, per target.thinkingLevelandlevelare accepted aliases.- Chain-wide
thinking_level, applied to targets that don't set one. An explicit per-target level always wins.
Only recognised levels are stripped as suffixes, so real model ids survive:
openrouter/fusion:xxs and z-ai/glm-5.2:free are matched as written.
Tunables
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch |
defaultChain |
[] |
Used when no key matches |
terminalFallback |
true |
One final openrouterTim/openrouter/fusion:xxs attempt, even without a config file; false excludes it from every route |
maxFailoversPerRequest |
4 |
Ordinary swaps per user prompt, plus one reserved terminal attempt. Also read from omp's retry.maxRetries |
settleDelayMs |
0 |
Extra grace after pi has fully settled, before rotating |
retryMode |
"replay" |
"replay" re-sends your prompt, "continue" sends retryInstruction |
retryInstruction |
"Continue from where you left off." |
Text used by "continue" |
compactBeforeRetry |
true |
Compact first if the target's context window is smaller |
requireNoToolCalls |
false |
Refuse to rotate when the failed run used tools (see below) |
notify |
true |
Show fallback notices |
restoreOnShutdown |
true |
Restore your session model on exit |
retryableStatusCodes |
404,408,409,425,429,5xx,52x | HTTP codes worth rotating for (404: the model is gone — another one serves) |
nonRetryableStatusCodes |
400,401,403,405,422 | Never rotate on these |
retryablePatterns |
see example | Case-insensitive regexes |
nonRetryablePatterns |
see example | Checked first, so auth beats a co-occurring 429 |
providerCapPatterns |
see example | Errors meaning the whole provider is out of quota (see below) |
modelUnavailablePatterns |
model_not_found, … |
Errors meaning this model is not served; quarantined for maxCooldownMs |
revertPolicy |
"cooldown-expiry" |
"cooldown-expiry" returns to your primary model once its cooldown ends; "never" stays on the fallback. omp's retry.fallbackRevertPolicy is read too |
cooldownMs |
300000 |
How long a failed primary is suppressed before revert (min 1000). A provider retry-after hint that is longer wins |
maxCooldownMs |
3600000 |
Ceiling for the cooldown, which doubles each time a restored primary fails again |
largeModelFallback |
{ "enabled": false, "targets": {} } |
On a context overflow, switch to a larger-context model instead of compacting (see Large-model fallback). Alias: large_model_fallback; omp's contextPromotion is read too |
Every tunable above except the pattern and status-code lists is also editable in the settings menu.
Returning to the primary model
After a failover, the model you were on gets a cooldown (cooldownMs). When it expires and the
session is idle, or at the next settled turn if it is busy, the session switches back. The switch is
recorded in the transcript as revert … → … and announced. It never happens mid-turn. If the
restored model fails again, the chain walks as usual and the next cooldown doubles, capped at
maxCooldownMs. Picking a model yourself cancels a pending revert. Disabling fallback (the
settings menu's Enabled toggle or /fallback-chains off) also cancels it, so no later timer switches
the model. "revertPolicy": "never" keeps the pre-0.4 behaviour of staying on the fallback.
Large-model fallback
"largeModelFallback": {
"enabled": true,
"targets": {
"openai-codex/gpt-5.5-mini": "openai-codex/gpt-5.5",
"cline-pass/*": "google/gemini-3-pro:high"
}
}
When a turn overflows the context window, pi normally compacts and retries. With a matching rule,
the session switches to the target instead, keeps the full history, and continues the turn. Keys are
globs over the overflowing provider/model; the first matching key wins. A target may be a glob (the
largest-window match wins) and may carry a :level. A target is used only if its known context window
is strictly larger and it has credentials; otherwise compaction runs as usual. A promoted session
stays on the larger model until /new (or until you pick another model).
- With pi-context loaded, pi-context asks
this extension first, over the versioned
context:promotionevent, before it compacts an overflow. Exactly one extension decides each compaction. The suppression is tied to that session's live pi-context registration; it ends when pi-context unloads. - Without pi-context (or after it unloads), this extension cancels pi's overflow compaction itself when it promotes, and otherwise leaves pi's default compaction untouched. A process-wide flag from an older pi-context release is ignored.
- An overflow that pi could not compact at all (too little history) is promoted at settle instead.
How a failure is handled
- An assistant turn ends with
stopReason === "error"; the failure is captured onagent_end. Nothing happens yet, because pi may still auto-retry, auto-compact, or run a queued continuation after that event. - The message is classified.
nonRetryablePatternswins over retryable ones; auth failures never rotate. Context overflow does not rotate, but withlargeModelFallbackon it may promote to a configured larger-context model before compaction (see Large-model fallback). - Pi reaches
agent_settled— the event that means no automatic retry, compaction, or continuation remains. This is when the chain is walked, so fallback never competes with pi's own recovery. Any new turn in between clears the captured failure. - If the failed run used tools, the rotation still happens but the retry is a continue
instruction (
retryInstruction), never a replay of your prompt. This avoids automatic prompt replay; the model must still respect already-completed tool work. SettingrequireNoToolCallstotrueskips tool-active failures entirely instead: the run stays stopped on the failed model (omp's old semantic; see below). - Otherwise the original chain is resolved against available, in-scope models and its remaining untried candidates are retained across failures. New cascade routes append behind them. Fusion is the final separate tier after ordinary exhaustion/budget.
- If the target's context window is smaller than the one you're leaving, pi compacts before retrying.
- The request is re-issued on the new model: your original prompt in replay mode without tools,
or
retryInstructionin continue mode or after tool activity. Only actual replay requires a captured user prompt; continuing a resumed/tool-active run does not.
Already-tried models are skipped, so a failure streak walks the chain once and then stops rather than
looping. The budget and the tried-set reset when a genuine new prompt arrives or a run finally
settles successfully, not at an intermediate agent_end. Auth, context overflow and cancellation
still take precedence over retryable symptoms, including transient internal/backend/server errors.
Quota caps are provider-wide. A failure matching providerCapPatterns — a 5-hour INFERENCE_CAP_ERROR,
a billing limit, a usage ceiling — marks the entire provider as out of quota for the rest of the streak:
no model behind that key is offered again, and the next configured target is chosen instead. Without
this, a chain whose first target is a sibling provider keeps swapping between two capped accounts
(gmail → cline-pass → gmail → …) and burns the whole budget without ever reaching your catch-all.
When the error carries a reset hint (The limit resets in 4h 43m) the cap is remembered past the streak
until that time, and the revert-to-primary timer waits at least that long (bounded by maxCooldownMs).
Unserved models are quarantined. A failure matching modelUnavailablePatterns (model_not_found,
Model 'x' not found, …) still rotates away, and also parks that one model for maxCooldownMs across
streaks, so a glob target cannot keep reselecting a model the provider does not serve yet. Siblings are
unaffected, and the model is offered again once the window lapses.
Exact chains win over glob leftovers; defaultChain is the tail. When a cascade reaches a model with
its own exact chain, those targets are tried before targets queued earlier from broader globs. Once every
matched target is spent, defaultChain gets a turn before the terminal fallback.
Glob targets resolve by family. When a glob target like neuralwatt/* matches several models, the
one sharing the failed model's family is offered first — fail off cline-pass/cline-pass/glm-5.3 and the
neuralwatt/* target starts at neuralwatt/glm-5.3, not whatever happens to be first in the catalogue.
Exact family match > same-family variant (glm-5.3-flex) > same vendor line (glm-5.2), and within a rank
the longest shared name prefix wins (kimi-k3-flex before kimi-k2.7-code-flex after kimi-k3.1-pro);
only then catalogue order.
Multi-session safety
One pi process can host several sessions at once — subagents spawned via the Agent tool / workflows,
/resume trees, RPC layers — and they all share this extension's module. All failover state is
keyed by session, so two sessions failing at the same moment (say, the same provider 429 wiping out
a parent and its subagent simultaneously) each get their own failover: one consumption cannot
eat another's pending failure, setModel/appendEntry/the re-issued prompt all land in the session
that failed, and a subagent starting or finishing never clears another session's streak or restore
model.
Model resolution is also scoped: when the session has a narrowed model set (--model fixes /
enabledModels settings — what /scoped-models shows), the chain only offers models inside that
set, so a fallback never swaps to something the user deliberately excluded.
Tool activity is tracked per session across pi's retry attempts. agent_end fires once per provider
call, and each auto-retry attempt's event carries only that attempt's error — so the flag that
decides "replay the prompt" vs "send a continue instruction" is accumulated from observed tool
traffic, not guessed from the failing event.
Each swap is also written to the transcript as a durable entry, so a fallback stays auditable after the fact.
Recovery protocol v1
Hosts may coordinate over the same per-session Pi event bus passed to their resource loader.
No sibling import is required; ordinary/root sessions also recover without a host observer.
Install host listeners before loading/binding extensions. The producer installs bus handlers at
session_start and removes them at shutdown.
pi-fallback-chains:recovery: snapshot updates.pi-fallback-chains:recovery:query:{ version: 1, sessionId, reply(snapshot) }. Replies synchronously for an owned live session; no reply means absent.pi-fallback-chains:recovery:cancel:{ version: 1, sessionId, reason }. Invalidates the epoch, clears timers and releases pending recovery synchronously; send this before calling the SDK session's abort operation.pi-fallback-chains:recovery:execution-error:{ version: 1, sessionId, event, message }. ForwardbindExtensions({ onError })errors whose event issend_user_message. Pi's extension send API returns void, so asynchronous submission rejection cannot be caught by its caller.
Snapshots contain version: 1, sessionId, epoch, requestId, increasing revision, phase,
pending, optional model and a sanitized category reason. Phases are idle, pending,
switching, compacting, retrying, settled, cancelled, failed. Raw prompts, host reasons,
and provider error messages are not published on the bus.
A lease is claimed synchronously during agent_settled, before model switching or a delay.
It remains pending through compaction, submission, replacement turns and further fallback attempts.
Only genuine final settlement, exhaustion/blocked recovery, submission failure or cancellation
releases it. A pending recovery that settles without an assistant outcome fails explicitly with
reason: "unknown-settlement" rather than retaining its lease forever; an ordinary assistant-less
settlement without a lease is unchanged. Recovery timers stay referenced. Superseded generations
cannot send late retries.
A host must wait for both no pending lease and an idle session before finalizing, unsubscribe
only afterward, and keep cancellation/usage/turn-limit tracking active throughout. Pi's initial
prompt() resolution or waitForIdle() alone does not include extension-owned recovery. This is
an opt-in local protocol, not an upstream Pi completion guarantee.
Input identity limit: Pi's input event exposes source: "extension" but no issuing extension
or submission ID. Input hooks can also asynchronously transform or consume the text. Consequently,
extension-sourced input conservatively stays within the current recovery streak; a genuinely new
request sent by another extension does not re-arm recovery after failure/cancellation. A boolean
"next input is ours" or a text-match marker cannot safely distinguish overlapping, transformed, or
late cancelled retries. Hosts starting a genuinely new request should use session.prompt() with
its default interactive source (or rpc where appropriate). Safe independent extension re-arming
needs an SDK-propagated submission identity or a separately agreed host request-boundary protocol.
Commands
| Command | Does |
|---|---|
/fallback-chains |
Settings menu (TUI): General, Revert, Large model (add/edit/delete rules), Status. Saves atomically to the project or user config file. Outside the TUI it prints status |
/fallback-chains status |
Status: config path, current model, matched chain, resolved targets, revert and large-model state, warnings |
/fallback-chains test <provider/model> |
Show the chain that model would take, and what it resolves to |
/fallback-chains reload |
Re-read config from disk |
/fallback-chains on|off |
Toggle for this session |
At startup the extension reports any target or chain key that matches no available model, so a typo or a removed model is visible immediately rather than at 3am:
[fallback] 2 target(s) match no available model: cline-pass/* → kilo/*, nim/*. Run /fallback-chains for details.
Migrating from oh-my-pi
Put your retry.fallbackChains block in a file and wrap or unwrap it — both shapes parse:
{ "retry": { "fallbackChains": { "cline-pass/*": ["neuralwatt/*"] }, "maxRetries": 10 } }
Differences to expect:
- Chain keys are anchored provider globs, not arbitrary patterns.
cline-pass/*means "any cline-pass model". A glob that spans providers (*) is still allowed. requireNoToolCallsdefaults tofalse, unlike omp. Tool traffic is tracked accurately across pi's retry attempts, so a tool-using failure rotates and sends a continue instruction rather than replaying your prompt — tools are still not re-run. Set it totrueonly if you want omp's exact behaviour of leaving such runs stopped on the failed model.nimandkilomay not exist in pi — they will be reported as inert rather than failing silently.- Think in
provider/model. A target with no provider prefix can never match, and is flagged as a warning. - Effort lives on the target, and also in pi's own
modelThinkingLevels; both are honoured.
Changes in 0.4.0
- Return to the primary model after a cooldown (
revertPolicy,cooldownMs,maxCooldownMs), doubling on repeat failures, never mid-turn. - Large-model fallback (
largeModelFallback): promote to a larger-context model on overflow instead of compacting, coordinated with pi-context overcontext:promotionv1. /fallback-chainsopens a settings menu;/fallback-chains statusprints the status report.- A config may now hold only
largeModelFallbackrules with emptychains.
Changes in 0.3.0
- Universal, disableable terminal Fusion recovery with reserved budget and stable candidate order.
- Producer-side recovery leases, synchronous query/cancel, sanitized submission-error handling.
- Narrow internal backend error classification; existing auth/context/abort guards retained.
- Deterministic lifecycle/cancellation/compaction/concurrency tests, without provider calls.
Development
No build step — pi loads TypeScript via jiti, and Node 22 strips types natively for tests.
node --test test/*.test.ts # or: npm test
node src/selfcheck.ts # or: npm run selfcheck
npm run typecheck # needs: npm install
Security
Extensions run with your full user permissions. Read the source before installing anything from a registry.
License
MIT