omp packages

@timmyjl12/pi-fallback-chains0.3.0

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.

timmyjl12 Published 11 days ago MIT ~199 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

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

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 and context overflow never rotate (rotating cannot fix either).
  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.

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 }. 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 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.
  • 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.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