@timmyjl12/pi-fallback-chains
v0.3.0
Published
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.
Maintainers
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 installingConfigure
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) |
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 and context overflow never rotate (rotating cannot fix either). - 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.
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) > 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 | Status: config path, current model, matched chain, resolved targets, 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.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 installSecurity
Extensions run with your full user permissions. Read the source before installing anything from a registry.
License
MIT
