Conversation
state.failedUntil accumulated one entry per failed model for the lifetime of a session and was never cleaned up. Expired cooldowns are now pruned on each session.error, keeping the map bounded. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Wrapped fetch errors (e.g. `new TypeError("fetch failed", { cause: {...} })`)
carry the real status code and message on `error.cause`. statusCode and
errorText now walk the cause chain (bounded to depth 5, safe against cycles),
so a 429/503 buried one or more levels deep still triggers fallback.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#4) The `/try.?again/` pattern matched non-transient messages (e.g. "try again with a different prompt"). It now requires the transient qualifier (later/soon/shortly/moment/in Ns). Adds a `retry_on_patterns` option: user-supplied regex sources (case- insensitive) appended to the built-in patterns. Invalid sources are skipped rather than crashing plugin init. isRetryableError takes an optional patterns argument (defaults preserved for existing callers). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Previously a single transient error switched the session to a fallback model permanently: chat.message forced currentModel for the rest of the session and never returned to the user's chosen model. Now, on chat.message, once the original model's cooldown has elapsed the plugin restores it (resetting attempts and notifying via toast) instead of staying on the fallback. Gated by the new `recover_original_model` option (default true); skipped if the original is in unavailable_models. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
state.attempts was a lifetime counter that only reset on manual switch or reset, so a few early transient blips could disable fallback for the rest of a long-running session. Fallback attempts are now timestamped and counted within a sliding window (`attempts_window_ms`, default 10 min). Once the window passes, older attempts no longer count against `max_attempts`. Set `attempts_window_ms: 0` to restore the previous absolute lifetime limit. status now also reports `windowAttempts` and `attemptsWindowMs`. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
On a transient error the plugin re-prompted the fallback model instantly, which can hammer a provider that is rate-limiting all of its models. Adds `backoff_ms` (default 0 = instant, unchanged) and `backoff_max_ms` (default 30s cap). When set, the plugin waits computeBackoff() = exponential growth per attempt with equal jitter, clamped to the cap, before the retry. computeBackoff is exported and unit-tested with an injected RNG. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
When `debug: true`, the plugin writes a single-line `[model-fallback] ...` trace to stderr for every fallback decision: why a retry fired or was skipped (disabled, no fallback_models, not retryable, max_attempts reached, stale error, no available model, in-flight), the backoff delay, the actual switch, and recovery to the original model. Default off; no output otherwise. OpenCode surfaces plugin stderr in its log stream, so this is greppable without exposing a logger to plugins. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The model_fallback_control status now reports `failureCounts` (how many retryable errors each model hit) and `switches` (last 20 model transitions with from/to/reason/timestamp; reasons: fallback, unavailable, recovery). Useful for diagnosing which models are flaky in a long session. Cleared on reset alongside the rest of the session state. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
) - Document all new options (retry_on_patterns, attempts_window_ms, backoff_ms, backoff_max_ms, recover_original_model, debug) in the options table and config example. - Add a `unavailable_models` vs `fallback_models` comparison table. - Update the "What Counts As Retryable" section (narrowed try-again, error.cause chain). - Add a Troubleshooting section covering the common failure modes. - Update the status tool description to mention failureCounts/switches. - Link the CHANGELOG. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…at.message (#12) Add test/e2e-server.test.ts: boots a real headless OpenCode server (createOpencodeServer) with a fake OpenAI provider, drives it through the SDK client, and observes plugin decisions via the SSE event stream. Covers status-path fallback, retry_on_patterns, cascade fallback, and recovery. The recovery e2e exposed a real bug: OpenCode invokes the chat.message hook for the plugin's own retry prompt. Since pendingModel was set only after promptAsync resolved, that echoed hook call was treated as a manual model switch, overwriting originalModel with the fallback and silently disabling recover_original_model in the live runtime. Set pendingModel/awaitingModel before prompting (and roll back if the retry is not accepted). Also: chat.message now backfills state models via getState when a model is present (session.created carries no model in the live runtime), and cascade-model uses 400 (OpenCode self-retries 429/503 before emitting session.error, masking plugin-driven cascade). New unit test reproduces the echoed-prompt runtime behavior. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
PR #3 (suppressed comment): errorText only walked `cause` for Error instances; plain-object wrappers fell through to JSON.stringify, which serializes a nested Error to {} and drops retryable text. Walk `cause` explicitly for records too, with the same depth cap. PR #2 (inline comment): the prune test only proved an expired cooldown made the model selectable again — pruning itself was unobservable. Expose active cooldowns as `cooling` in model_fallback_control status and assert the expired entries are gone. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Bump version, finalize CHANGELOG, add @opencode-ai/sdk devDependency (used by the in-process e2e suite; was resolving only transitively). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v1.1.0
13 PRs merged into
devsince v1.0.8. Full changelog in CHANGELOG.md.Added
recover_original_model, default on) (feat: recover to original model after cooldown expires #5)max_attempts(attempts_window_ms) (feat: make max_attempts a sliding window, not a lifetime cap #6)backoff_ms,backoff_max_ms) (feat: optional exponential backoff with jitter before re-prompting #7)debug) (feat: optional debug logging of fallback decisions #8)failureCounts,switcheshistory, and activecoolingcooldowns instatus(feat: per-model failure counts and switch history in status #9, fix: address Copilot review feedback from PRs #2 and #3 #13)retry_on_patterns(feat: narrow try-again pattern, add configurable retry_on_patterns #4)Changed
try againpattern narrowed to transient qualifiers (feat: narrow try-again pattern, add configurable retry_on_patterns #4)error.causechains, including plain-object wrappers aroundErrorcauses (feat: detect retryable errors through error.cause chain #3, fix: address Copilot review feedback from PRs #2 and #3 #13)Fixed
chat.messageno longer masquerades as a manual model switch (test: in-process e2e via SDK server; fix recovery race with echoed chat.message #12)Tests
createOpencodeServer+ SSE event stream (test: in-process e2e via SDK server; fix recovery race with echoed chat.message #12)🤖 Generated with Claude Code