Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ behavior unless noted.
- **Configurable retry patterns.** New `retry_on_patterns` option appends
user-supplied case-insensitive regex sources to the built-in patterns.
Invalid sources are skipped rather than failing plugin init. (#4)
- **Active cooldowns in `status`.** `model_fallback_control status` now
reports `cooling` (model → cooldown-expiry timestamp), making cooldown
pruning observable.

### Changed

Expand All @@ -46,7 +49,9 @@ behavior unless noted.
- **Retryable errors are now detected through `error.cause`.** `statusCode`
and `errorText` walk the cause chain (bounded to depth 5, safe against
cycles), so a 429/503 buried in a wrapped fetch error triggers fallback.
(#3)
Plain-object wrappers whose `cause` is an `Error` are also handled —
`JSON.stringify` serializes nested `Error`s to `{}`, which previously
dropped retryable text. (#3)

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Actions:

- `enable`: enable fallback for the current session.
- `disable`: disable fallback for the current session.
- `status`: return effective state, current model, attempts, windowed attempt count, per-model `failureCounts`, recent `switches`, and configured fallbacks.
- `status`: return effective state, current model, attempts, windowed attempt count, per-model `failureCounts`, active `cooling` cooldowns (model → expiry timestamp), recent `switches`, and configured fallbacks.
- `reset`: clear session state and return to the config default.

Session overrides are in memory only. They disappear when the session is deleted or OpenCode restarts.
Expand Down
10 changes: 10 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,15 @@ function errorText(error: unknown, depth = 0): string {
return text
}

// Plain objects: JSON.stringify drops nested Error values (they serialize
// to {}), so walk `cause` explicitly with the same depth cap.
if (isRecord(error) && depth < MAX_CAUSE_DEPTH && error.cause !== undefined) {
const { cause, ...rest } = error
const causeText = errorText(cause, depth + 1)
const ownText = errorText(rest, MAX_CAUSE_DEPTH)
return causeText ? `${ownText} ${causeText}` : ownText
}

try {
return JSON.stringify(error)
} catch {
Expand Down Expand Up @@ -436,6 +445,7 @@ function statusText(config: Config, state: SessionState | undefined): string {
fallbackModels: config.fallbackModels,
unavailableModels: config.unavailableModels,
failureCounts: state ? Object.fromEntries(state.failureCounts) : {},
cooling: state ? Object.fromEntries(state.failedUntil) : {},
switches: state?.switches ?? [],
})
}
Expand Down
22 changes: 22 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,16 @@ describe("model fallback plugin", () => {
await emitSessionError(plugin, { providerID: "anthropic", modelID: "claude-sonnet" })
expect(prompts).toHaveLength(3)
expect(prompts[2]?.body.model).toEqual({ providerID: "openai", modelID: "gpt-5.1-codex" })

// pruning is observable through status: the expired opus/codex cooldowns
// were removed, only the fresh sonnet cooldown remains
const status = await plugin.tool.model_fallback_control.execute({ action: "status" }, {
sessionID: "ses_1", messageID: "msg_tool", agent: "build",
directory: "/repo", worktree: "/repo",
abort: new AbortController().signal,
metadata: () => undefined, ask: async () => undefined,
})
expect(Object.keys(JSON.parse(String(status)).cooling)).toEqual(["anthropic/claude-sonnet"])
})

test("#given first fallback is also unavailable #when retrying a failed model #then it skips to the next fallback", async () => {
Expand Down Expand Up @@ -1028,6 +1038,18 @@ describe("helpers", () => {
expect(isRetryableError(err, [])).toBe(true)
})

test("#given a plain object whose cause is an Error with retryable text #when classified #then it is retryable", () => {
// JSON.stringify(new Error(...)) yields "{}", so the text would be lost
// without explicit cause traversal for non-Error wrappers
expect(isRetryableError({ message: "request failed", cause: new Error("model is overloaded") }, [])).toBe(true)
})

test("#given a circular plain-object cause chain #when classified by text #then it terminates without matching", () => {
const err: Record<string, unknown> = { message: "boom" }
err.cause = { cause: err }
expect(isRetryableError(err, [])).toBe(false)
})

test("#given a circular cause chain #when classified #then it terminates without matching", () => {
const err: Record<string, unknown> = { message: "boom" }
err.cause = err
Expand Down
Loading