Native model failover for OpenCode v2.
When a session fails because a model is rate-limited, overloaded, or otherwise unreachable, this plugin moves the session onto the next healthy model in a chain you define. It runs entirely on the OpenCode v2 plugin API: no HTTP client, no vendor SDK, no runtime dependencies.
OpenCode v2 has a different plugin API from v1, and the fallback plugins already on npm are written against the v1 API, so they do not load in v2. This plugin is v2-native.
- No chain ships with it. You name the models. Nothing about the author's provider setup is baked in, and the default chain is empty.
- Failures are filtered. Only retryable errors (rate limits, timeouts, provider and network errors, 429/502/503/504) trigger a failover. A validation error would fail on every model, so it is ignored instead of burning attempts.
- Prompt replay is opt-in.
autoRetryis off by default, because re-sending a prompt is a state-changing action. - Cooldown. A model whose switch fails is skipped for
cooldownMs, so a broken target is not retried on every failure. - Forward progress. Each failure advances along your chain instead of re-selecting the entry that just failed, so later providers are reachable.
- OpenCode v2 (
opencode --versionreports 2.x). - Node >= 22.
opencode plugin add opencode-model-failoveropencode plugin add github:nabheet/opencode-model-failoverOr add it to opencode.json(c) directly:
Copy this directory to .opencode/plugins/opencode-model-failover/ (project) or
~/.config/opencode/plugins/opencode-model-failover/ (global), then list it:
{
"plugins": [{ "package": "./.opencode/plugins/opencode-model-failover" }]
}The v2 loader resolves index.js inside the directory; the package.json
main field is not consulted.
The plugin does nothing until you supply a chain. Pass options through the plugin entry in your OpenCode config:
{
"plugins": [
{
"package": "opencode-model-failover",
"options": {
"fallbackModels": ["provider-a/model-x", "provider-b/model-y"],
"maxAttempts": 2,
"cooldownMs": 60000,
"autoRetry": false,
"notify": true,
"logFile": "/tmp/model-failover.log"
}
}
]
}| Option | Type | Default | Description |
|---|---|---|---|
fallbackModels |
string[] |
[] |
Ordered provider/model chain, tried in order. |
maxAttempts |
number |
2 |
Failovers per failure sequence before giving up. |
cooldownMs |
number |
60000 |
Skip a model this long after a failed switch. |
autoRetry |
boolean |
false |
Replay the last user message after switching. |
notify |
boolean |
true |
Log each failover to stderr. |
logFile |
string |
"" |
Also append log lines to this file. |
A provider/model string is split on the first /. Entries without a / are
dropped from the chain.
The plugin subscribes to the v2 event stream:
ctx.event.subscribe({ signal })
// session.execution.failed -> { data: { sessionID, error: { type, message } } }
// session.execution.succeeded -> { data: { sessionID } }
// session.execution.interrupted -> { data: { sessionID } }
// session.model.selected -> { data: { sessionID, model: { providerID, id } } }
// session.step.started -> { data: { sessionID, model: { providerID, id } } }
// session.deleted -> { data: { sessionID } }On a retryable session.execution.failed it:
- reads the models that actually resolve with
ctx.model.list(); - picks the first chain entry that resolves, is not cooling down, is not the model the session is already on, and has not been tried in this failure sequence;
- moves the session with
ctx.session.switchModel({ sessionID, model }).
If autoRetry is on, it then reads the transcript with
ctx.session.context({ sessionID }) and replays the last user message with
ctx.session.prompt({ sessionID, text }).
maxAttempts counts failovers within one failure sequence, not per session
lifetime, and each completed switch is remembered so the next failure moves to
the entry after it. That is what lets a session reach a later provider: without
it every failure re-selected the first available entry.
- A failed
switchModelis a provider problem, not a failed inference: the model is cooled down forcooldownMs, but no attempt is used and the chain position does not move. - A failed inference does not cool the model down. It advances this session's position instead, so a sibling session can still land on that model: the failure is the model's problem for this request, and a process-wide cooldown would take a working model away from everyone.
session.execution.succeededandsession.execution.interruptedend the sequence, so the next failure starts at the top of the chain again.session.model.selectedandsession.step.startedrecord the model the session is on, so a failure never selects that model again in the same sequence.session.deleteddrops the session's state.
The id/setup plugin shape, the plugins config key, and the methods above
are the whole v2 surface it uses.
A server-side plugin is easy to miss, so every decision is logged with a
[model-failover] prefix. Set notify: false to silence stderr, and
logFile (or the MODEL_FALLBACK_LOG environment variable) to append to a
file:
MODEL_FALLBACK_LOG=/tmp/model-failover.log opencodeTypical lines:
[model-failover] 2026-01-01T00:00:00.000Z setup: chain=[provider-a/model-x] maxAttempts=2 autoRetry=false
[model-failover] 2026-01-01T00:00:01.000Z failed event: session=ses_123 error=provider.rateLimit msg=429
[model-failover] 2026-01-01T00:00:01.010Z availability=12 models; looking for next in chain
[model-failover] 2026-01-01T00:00:01.020Z session ses_123 moved to provider-a/model-x (attempt 1/2)
- Targets OpenCode v2 (the
pluginsconfig key and theid/setupplugin definition). OpenCode v1 used a different plugin API and is not supported. - No runtime dependencies. Only
node:builtins are imported.
Every push to a PR publishes an installable beta to the npm beta dist-tag:
opencode plugin add opencode-model-failover@betanpm install
npm run lint # biome check + markdownlint-cli2
npm run lint:fix # apply safe fixes
npm test # node:test
npm run check # lint + testAll publishing happens in GitHub Actions via npm trusted publishing (OIDC provenance). Never publish from a local shell.
.github/workflows/ci.yml is the single workflow file (npm allows one trusted
publisher per package; the workflow filename must stay ci.yml):
test— lint + plugin tests onmainand PRs (Node 24).- push to
main→publishjob publishes the next patch tolatest, pushes avX.Y.Ztag, and opens a GitHub release. The version is the highest of the newest stablevX.Y.Ztag and the registry'slatest, plus a patch, so repeated merges never collide; an intentional minor/major bump inpackage.jsonis honored. A publish that hitsE409bumps a patch and retries (bounded), because the registry read can lag a fresh publish. - tag
v*-beta*→publishjob publishesbetaplus a prerelease GitHub release. - every PR push →
prereleasejob publishes<version>-beta.<run_number>tobeta(same-repo, non-draft, non-dependabot PRs; re-runs deduped). - manual dispatch → next
-beta.Xstaging publish frommain, no git change.
One-time bootstrap: npm cannot attach a trusted publisher to a package that does
not exist yet, so opencode-model-failover must be published once by hand before
the workflow can publish. Then configure the trusted publisher on npmjs.com:
repository nabheet/opencode-model-failover, workflow filename ci.yml, and
tick Allow npm publish. A trusted publisher created after Sep 03 2026 is
stage-only by default, and stage-only sends CI publishes to the staging area to
wait for a 2FA approval instead of going live. A new config must also complete
its first successful publish within 2 days or it expires.
MIT — see LICENSE.
{ "$schema": "https://opencode.ai/config.json", "plugins": ["opencode-model-failover"] }