@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.

Packages

Package details

extension

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:

  1. $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.
  2. <project>/.pi/pi-fallback-chains.json
  3. ~/.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

  1. An exact key with no wildcards.
  2. Otherwise the matching key with the most literal characters.
  3. Otherwise defaultChain, if set.
  4. 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"] }
} }
  • :level suffix — :off|minimal|low|medium|high|xhigh|max.
  • thinking_level field, per target. thinkingLevel and level are 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:promotion event, 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

  1. An assistant turn ends with stopReason === "error"; the failure is captured on agent_end. Nothing happens yet, because pi may still auto-retry, auto-compact, or run a queued continuation after that event.
  2. The message is classified. nonRetryablePatterns wins over retryable ones; auth failures never rotate. Context overflow does not rotate, but with largeModelFallback on it may promote to a configured larger-context model before compaction (see Large-model fallback).
  3. 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.
  4. 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. Setting requireNoToolCalls to true skips tool-active failures entirely instead: the run stays stopped on the failed model (omp's old semantic; see below).
  5. 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.
  6. If the target's context window is smaller than the one you're leaving, pi compacts before retrying.
  7. The request is re-issued on the new model: your original prompt in replay mode without tools, or retryInstruction in 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 }. Forward bindExtensions({ onError }) errors whose event is send_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.
  • requireNoToolCalls defaults to false, 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 to true only if you want omp's exact behaviour of leaving such runs stopped on the failed model.
  • nim and kilo may 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 over context:promotion v1.
  • /fallback-chains opens a settings menu; /fallback-chains status prints the status report.
  • A config may now hold only largeModelFallback rules with empty chains.

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