From fe46e7f3f52b0b13f7828c3478a2499c9dedb9f5 Mon Sep 17 00:00:00 2001 From: azumag Date: Sat, 17 Oct 2026 06:21:22 +0900 Subject: [PATCH 1/2] feat(v2): fall back to the next model instead of only waiting In v2, fallbackModels is now an ordered chain. When a request fails with a rate limit or a transient upstream error, a session whose model is in the chain is switched (session.switchModel) to the next model after it and retried about one second later. Once the chain is exhausted, fallbackMode decides: cycle back to the session's own model after a cooldown, retry-last, stop, or wait (never switch). A session whose model is not in the chain keeps the wait behaviour. The next real user prompt puts the session back on its own model. OpenCode 2.0.31 keeps retrying past attempt 5 (its ceiling is about 10), so the timer that resumed at attempt 5 pushed an empty user message into a turn that was still running. A turn is now resumed only after session.execution.failed with a retryable error; interrupted turns stay stopped. Co-Authored-By: Claude Opus 5.5 --- README.md | 43 +++++- __tests__/v2.test.ts | 324 +++++++++++++++++++++++++++++++++++++++---- package-lock.json | 4 +- package.json | 2 +- v2.ts | 197 ++++++++++++++++++++++---- 5 files changed, 508 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index a5abf6d..3f7d22d 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,45 @@ OpenCode v1 must stay on the latest 1.x release: Version 2 is published under the `opencode-v2` npm tag so unpinned OpenCode v1 installations do not receive the incompatible v2 entry point. -The v2 entry point currently implements `fallbackMode: "wait"`. Model-switching -modes remain available in the v1-compatible 1.x line. - -In v2 wait mode, `cooldownMs` is the initial delay. Consecutive rate-limit -failures use exponential backoff (`1x`, `2x`, `4x`, ...), capped at one hour. -A new user prompt resets the delay to `cooldownMs`; automatic resume attempts +In v2, `fallbackModels` is an ordered chain. When a request fails with a rate +limit or a transient upstream error, a session whose model is in the chain is +switched to the next model after it and retried about one second later. Once +every model from the session's own model to the end of the chain has failed, +`fallbackMode` decides what happens next: + +| Mode | v2 behavior once the chain is exhausted | +|------|------------------------------------------| +| `"cycle"` | Go back to the session's own model and retry after a cooldown | +| `"retry-last"` | Stay on the last model and retry it after a cooldown | +| `"stop"` | Let the turn fail | +| `"wait"` | Never switch models: retry the session's model after a cooldown | + +A session whose model is not in `fallbackModels` is never switched; it is +retried on its own model after a cooldown, as in `"wait"`. + +The cooldown starts at `cooldownMs` and doubles with every consecutive +exhausted pass (`1x`, `2x`, `4x`, ...), capped at one hour. The next user prompt +puts the session back on its own model (unless someone picked another model in +the meantime) and resets the delay to `cooldownMs`; automatic resume attempts preserve the existing backoff sequence. +OpenCode stops retrying a turn after its own retry limit even when the plugin +asks for another try. When such a turn fails with a rate limit or transient +upstream error, the plugin resumes it once the next try is due. A turn that was +interrupted (by the user, or by a bot that gave up) is not resumed. + +```json +{ + "enabled": true, + "fallbackMode": "cycle", + "cooldownMs": 60000, + "fallbackModels": [ + { "providerID": "opencode", "modelID": "glm-5.3-flash-free" }, + { "providerID": "opencode", "modelID": "deepseek-v4.1-flash-free" } + ] +} +``` + OpenCode will automatically install the plugin on startup. This release is tested with OpenCode `1.18.16`. Older OpenCode releases may diff --git a/__tests__/v2.test.ts b/__tests__/v2.test.ts index 35ad1fc..880e5ea 100644 --- a/__tests__/v2.test.ts +++ b/__tests__/v2.test.ts @@ -7,6 +7,37 @@ vi.mock("fs", () => ({ readFileSync: vi.fn(), })); +/** A controllable stand-in for context.event.subscribe(). */ +function eventFeed() { + const queue: any[] = []; + let wake: (() => void) | undefined; + return { + push(event: any) { + queue.push(event); + wake?.(); + }, + subscribe: vi.fn((options?: { signal?: AbortSignal }) => ({ + async *[Symbol.asyncIterator]() { + let closed = false; + options?.signal?.addEventListener("abort", () => { + closed = true; + wake?.(); + }); + while (!closed) { + if (queue.length > 0) { + yield queue.shift(); + continue; + } + await new Promise((resolve) => { + wake = resolve; + }); + wake = undefined; + } + }, + })), + }; +} + describe("OpenCode v2 plugin", () => { afterEach(() => { vi.useRealTimers(); @@ -26,7 +57,7 @@ describe("OpenCode v2 plugin", () => { cooldownMs: 60000, fallbackModels: [], })); - let retryHook: ((event: any) => void) | undefined; + let retryHook: ((event: any) => Promise | void) | undefined; const promptDispose = vi.fn(); const retryDispose = vi.fn(); const cleanup = await plugin.setup({ @@ -46,11 +77,11 @@ describe("OpenCode v2 plugin", () => { error: { type: "provider", message: "Rate limit exceeded", status: 429 }, decision: { retry: false }, }; - retryHook?.(event); + await retryHook?.(event); expect(event.decision).toEqual({ retry: true, delay: 60000 }); const secondEvent = { ...event, attempt: 2, decision: { retry: false } }; - retryHook?.(secondEvent); + await retryHook?.(secondEvent); expect(secondEvent.decision).toEqual({ retry: true, delay: 120000 }); await cleanup?.(); @@ -67,7 +98,7 @@ describe("OpenCode v2 plugin", () => { fallbackModels: [], })); let promptHook: ((event: any) => void) | undefined; - let retryHook: ((event: any) => void) | undefined; + let retryHook: ((event: any) => Promise | void) | undefined; const cleanup = await plugin.setup({ location: { directory: "/test" }, session: { @@ -88,7 +119,7 @@ describe("OpenCode v2 plugin", () => { error: { status: 429, message: "Rate limit exceeded" }, decision: { retry: false }, }; - retryHook?.(event); + await retryHook?.(event); } expect(event.decision).toEqual({ retry: true, delay: 3600000 }); @@ -99,7 +130,7 @@ describe("OpenCode v2 plugin", () => { error: { status: 429, message: "Rate limit exceeded" }, decision: { retry: false }, }; - retryHook?.(event); + await retryHook?.(event); expect(event.decision).toEqual({ retry: true, delay: 3600000 }); promptHook?.({ sessionID: "session-1", prompt: { text: "new request" } }); @@ -109,7 +140,7 @@ describe("OpenCode v2 plugin", () => { error: { status: 429, message: "Rate limit exceeded" }, decision: { retry: false }, }; - retryHook?.(event); + await retryHook?.(event); expect(event.decision).toEqual({ retry: true, delay: 60000 }); await cleanup?.(); @@ -124,8 +155,9 @@ describe("OpenCode v2 plugin", () => { cooldownMs: 1000, fallbackModels: [], })); - let retryHook: ((event: any) => void) | undefined; + let retryHook: ((event: any) => Promise | void) | undefined; const prompt = vi.fn().mockResolvedValue(undefined); + const events = eventFeed(); const cleanup = await plugin.setup({ location: { directory: "/test" }, session: { @@ -135,15 +167,16 @@ describe("OpenCode v2 plugin", () => { }), prompt, }, + event: events, }); + const error = { status: 429, message: "Rate limit exceeded" }; - retryHook?.({ - sessionID: "session-1", - attempt: 5, - error: { status: 429, message: "Rate limit exceeded" }, - decision: { retry: false }, - }); - await vi.advanceTimersByTimeAsync(1250); + await retryHook?.({ sessionID: "session-1", attempt: 2, error, decision: { retry: false } }); + // OpenCode's own retry ceiling ends the turn despite the retry request. + events.push({ type: "session.execution.failed", data: { sessionID: "session-1", error } }); + await vi.advanceTimersByTimeAsync(1999); + expect(prompt).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); expect(prompt).toHaveBeenCalledWith({ sessionID: "session-1", text: "", resume: true }); @@ -158,7 +191,7 @@ describe("OpenCode v2 plugin", () => { cooldownMs: 60000, fallbackModels: [], })); - let retryHook: ((event: any) => void) | undefined; + let retryHook: ((event: any) => Promise | void) | undefined; const cleanup = await plugin.setup({ location: { directory: "/test" }, session: { @@ -179,11 +212,11 @@ describe("OpenCode v2 plugin", () => { }, decision: { retry: true, delay: 2000 }, }; - retryHook?.(event); + await retryHook?.(event); expect(event.decision).toEqual({ retry: true, delay: 60000 }); const secondEvent = { ...event, attempt: 3, decision: { retry: false } }; - retryHook?.(secondEvent); + await retryHook?.(secondEvent); expect(secondEvent.decision).toEqual({ retry: true, delay: 120000 }); await cleanup?.(); @@ -198,8 +231,9 @@ describe("OpenCode v2 plugin", () => { cooldownMs: 1000, fallbackModels: [], })); - let retryHook: ((event: any) => void) | undefined; + let retryHook: ((event: any) => Promise | void) | undefined; const prompt = vi.fn().mockResolvedValue(undefined); + const events = eventFeed(); const cleanup = await plugin.setup({ location: { directory: "/test" }, session: { @@ -209,21 +243,255 @@ describe("OpenCode v2 plugin", () => { }), prompt, }, + event: events, }); + const error = { + type: "provider.internal", + message: "Error from provider (Console Go): Upstream request failed: Endpoint is unavailable.", + }; - retryHook?.({ - sessionID: "session-1", - attempt: 5, - error: { - type: "provider.internal", - message: "Error from provider (Console Go): Upstream request failed: Endpoint is unavailable.", + await retryHook?.({ sessionID: "session-1", attempt: 2, error, decision: { retry: false } }); + // OpenCode's own retry ceiling ends the turn despite the retry request. + events.push({ type: "session.execution.failed", data: { sessionID: "session-1", error } }); + await vi.advanceTimersByTimeAsync(1999); + expect(prompt).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(prompt).toHaveBeenCalledWith({ sessionID: "session-1", text: "", resume: true }); + + await cleanup?.(); + }); + + it("does not resume an interrupted turn or a failure it was not retrying", async () => { + vi.useFakeTimers(); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, + fallbackMode: "wait", + cooldownMs: 1000, + fallbackModels: [], + })); + let retryHook: ((event: any) => Promise | void) | undefined; + const prompt = vi.fn().mockResolvedValue(undefined); + const events = eventFeed(); + const cleanup = await plugin.setup({ + location: { directory: "/test" }, + session: { + hook: vi.fn(async (name, callback) => { + if (name === "retry") retryHook = callback; + return { dispose: vi.fn() }; + }), + prompt, }, - decision: { retry: false }, + event: events, }); - await vi.advanceTimersByTimeAsync(1250); + const rateLimit = { status: 429, message: "Rate limit exceeded" }; - expect(prompt).toHaveBeenCalledWith({ sessionID: "session-1", text: "", resume: true }); + // never retried by this plugin + events.push({ type: "session.execution.failed", data: { sessionID: "session-2", error: rateLimit } }); + // retried, then stopped by the user (or a bot giving up) + await retryHook?.({ sessionID: "session-1", attempt: 2, error: rateLimit, decision: { retry: false } }); + events.push({ type: "session.execution.interrupted", data: { sessionID: "session-1", reason: "user" } }); + // retried, then failed for an unrelated reason + await retryHook?.({ sessionID: "session-3", attempt: 2, error: rateLimit, decision: { retry: false } }); + events.push({ type: "session.execution.failed", data: { sessionID: "session-3", error: { status: 401, message: "Invalid API key" } } }); + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(prompt).not.toHaveBeenCalled(); await cleanup?.(); }); + + describe("model fallback", () => { + const GLM = { providerID: "opencode", id: "glm-5.3-flash-free" }; + const DEEPSEEK = { providerID: "opencode", id: "deepseek-v4.1-flash-free" }; + const OTHER = { providerID: "opencode-go", id: "kimi-k3" }; + + async function setupFallback(fallbackMode: string, sessionModel: { providerID: string; id: string } = GLM) { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, + fallbackMode, + cooldownMs: 60000, + fallbackModels: [ + { providerID: GLM.providerID, modelID: GLM.id }, + { providerID: DEEPSEEK.providerID, modelID: DEEPSEEK.id }, + ], + })); + const hooks: Record Promise | void> = {}; + let current = sessionModel; + const switchModel = vi.fn(async ({ model }: { model: typeof GLM }) => { + current = model; + }); + const get = vi.fn(async () => ({ model: current })); + const prompt = vi.fn().mockResolvedValue(undefined); + const events = eventFeed(); + const cleanup = await plugin.setup({ + location: { directory: "/test" }, + session: { + hook: vi.fn(async (name, callback) => { + hooks[name] = callback; + return { dispose: vi.fn() }; + }), + prompt, + switchModel, + get, + }, + event: events, + } as any); + const fail = async (attempt: number, message = "Rate limit exceeded") => { + const event = { + sessionID: "session-1", + model: current, + attempt, + error: { status: 429, message }, + decision: { retry: true, delay: 2000 } as any, + }; + await hooks.retry(event); + return event.decision; + }; + const userPrompt = (text: string) => hooks.prompt({ sessionID: "session-1", prompt: { text } }); + const retry = (event: any) => hooks.retry(event); + return { switchModel, get, prompt, events, fail, retry, userPrompt, cleanup, model: () => current }; + } + + it("switches to the next model in the chain and retries almost at once", async () => { + const t = await setupFallback("cycle"); + expect(await t.fail(2)).toEqual({ retry: true, delay: 1000 }); + expect(t.switchModel).toHaveBeenCalledWith({ sessionID: "session-1", model: DEEPSEEK }); + expect(t.model()).toEqual(DEEPSEEK); + await t.cleanup?.(); + }); + + it("also falls back on a provider-internal 'Endpoint is unavailable' error", async () => { + const t = await setupFallback("cycle"); + expect(await t.fail(2, "Upstream request failed: Endpoint is unavailable.")).toEqual({ retry: true, delay: 1000 }); + expect(t.model()).toEqual(DEEPSEEK); + await t.cleanup?.(); + }); + + it("leaves non-retryable errors to OpenCode", async () => { + const t = await setupFallback("cycle"); + const event = { + sessionID: "session-1", + model: GLM, + attempt: 2, + error: { status: 401, message: "Invalid API key" }, + decision: { retry: false }, + }; + await t.retry(event); + expect(event.decision).toEqual({ retry: false }); + expect(t.switchModel).not.toHaveBeenCalled(); + await t.cleanup?.(); + }); + + it("cycle: once every model failed, goes back to the session's model with exponential backoff", async () => { + const t = await setupFallback("cycle"); + expect(await t.fail(2)).toEqual({ retry: true, delay: 1000 }); // GLM -> DeepSeek + expect(await t.fail(3)).toEqual({ retry: true, delay: 60000 }); // DeepSeek failed too -> GLM after cooldown + expect(t.model()).toEqual(GLM); + expect(await t.fail(4)).toEqual({ retry: true, delay: 1000 }); // GLM -> DeepSeek again + expect(t.model()).toEqual(DEEPSEEK); + expect(await t.fail(5)).toEqual({ retry: true, delay: 120000 }); // second full pass doubles the wait + expect(t.model()).toEqual(GLM); + await t.cleanup?.(); + }); + + it("retry-last: stays on the last fallback and backs off", async () => { + const t = await setupFallback("retry-last"); + await t.fail(2); + expect(await t.fail(3)).toEqual({ retry: true, delay: 60000 }); + expect(t.model()).toEqual(DEEPSEEK); + expect(await t.fail(4)).toEqual({ retry: true, delay: 120000 }); + expect(t.model()).toEqual(DEEPSEEK); + expect(t.switchModel).toHaveBeenCalledTimes(1); + await t.cleanup?.(); + }); + + it("stop: gives the turn up once every model failed", async () => { + const t = await setupFallback("stop"); + await t.fail(2); + expect(await t.fail(3)).toEqual({ retry: false }); + await t.cleanup?.(); + }); + + it("restores the session's own model on the next user prompt", async () => { + const t = await setupFallback("cycle"); + await t.fail(2); + expect(t.model()).toEqual(DEEPSEEK); + await t.userPrompt(""); + expect(t.model()).toEqual(DEEPSEEK); // the automatic resume keeps the fallback + await t.userPrompt("next request"); + expect(t.model()).toEqual(GLM); + // and the streak starts over: GLM failing again switches straight to DeepSeek + expect(await t.fail(2)).toEqual({ retry: true, delay: 1000 }); + expect(t.model()).toEqual(DEEPSEEK); + await t.cleanup?.(); + }); + + it("does not override a model someone else picked after the fallback", async () => { + const t = await setupFallback("cycle"); + await t.fail(2); + t.get.mockResolvedValueOnce({ model: OTHER }); + t.switchModel.mockClear(); + await t.userPrompt("next request"); + expect(t.switchModel).not.toHaveBeenCalled(); + await t.cleanup?.(); + }); + + it("waits on the same model when the session's model is not in the chain", async () => { + const t = await setupFallback("cycle", OTHER); + expect(await t.fail(2)).toEqual({ retry: true, delay: 60000 }); + expect(await t.fail(3)).toEqual({ retry: true, delay: 120000 }); + expect(t.switchModel).not.toHaveBeenCalled(); + expect(t.model()).toEqual(OTHER); + await t.cleanup?.(); + }); + + it("starts from the session's position in the chain", async () => { + const t = await setupFallback("cycle", DEEPSEEK); + expect(await t.fail(2)).toEqual({ retry: true, delay: 60000 }); + expect(t.switchModel).not.toHaveBeenCalled(); + await t.cleanup?.(); + }); + + it("waits instead when the model switch is rejected", async () => { + const t = await setupFallback("cycle"); + t.switchModel.mockRejectedValueOnce(new Error("session not found")); + expect(await t.fail(2)).toEqual({ retry: true, delay: 60000 }); + await t.cleanup?.(); + }); + + it("wait mode never switches models", async () => { + const t = await setupFallback("wait"); + expect(await t.fail(2)).toEqual({ retry: true, delay: 60000 }); + expect(t.switchModel).not.toHaveBeenCalled(); + await t.cleanup?.(); + }); + + it("resumes a turn OpenCode failed at its retry ceiling, on the next model", async () => { + vi.useFakeTimers(); + const t = await setupFallback("cycle"); + await t.fail(2); // GLM -> DeepSeek + t.events.push({ type: "session.execution.failed", data: { sessionID: "session-1", error: { status: 429, message: "Rate limit exceeded" } } }); + await vi.advanceTimersByTimeAsync(0); + expect(t.model()).toEqual(GLM); // DeepSeek counted as failed: back to GLM after the cooldown + await vi.advanceTimersByTimeAsync(59999); + expect(t.prompt).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(t.prompt).toHaveBeenCalledWith({ sessionID: "session-1", text: "", resume: true }); + await t.cleanup?.(); + }); + + it("a new user prompt cancels a pending resume", async () => { + vi.useFakeTimers(); + const t = await setupFallback("cycle"); + await t.fail(2); + t.events.push({ type: "session.execution.failed", data: { sessionID: "session-1", error: { status: 429, message: "Rate limit exceeded" } } }); + await vi.advanceTimersByTimeAsync(0); + await t.userPrompt("never mind, do this instead"); + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(t.prompt).not.toHaveBeenCalled(); + await t.cleanup?.(); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index 6cb7530..40748d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.3", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.3", + "version": "2.1.0", "license": "MIT", "devDependencies": { "@opencode-ai/plugin": "1.18.16", diff --git a/package.json b/package.json index f578b05..1d47e93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.3", + "version": "2.1.0", "description": "OpenCode plugin that automatically switches to fallback models when rate limited", "main": "dist/v2.js", "types": "dist/v2.d.ts", diff --git a/v2.ts b/v2.ts index 74121be..d64ec0d 100644 --- a/v2.ts +++ b/v2.ts @@ -1,4 +1,5 @@ import { loadConfig } from "./src/utils/config.js"; +import type { FallbackModel } from "./src/types/index.js"; type RetryError = { readonly type?: string; @@ -6,8 +7,16 @@ type RetryError = { readonly status?: number; }; +type ModelRef = { + readonly providerID: string; + readonly id: string; + readonly variant?: string; +}; + type RetryEvent = { readonly sessionID: string; + /** Model of the request that failed. Present on OpenCode v2 GA. */ + readonly model?: ModelRef; readonly error: RetryError; readonly attempt: number; decision: { retry: false } | { retry: true; delay: number }; @@ -36,11 +45,25 @@ type V2Context = { text: string; resume: boolean; }): Promise; + // Optional so a host without model switching still gets wait behaviour. + switchModel?(input: { sessionID: string; model: ModelRef }): Promise; + get?(input: { sessionID: string }): Promise<{ readonly model?: ModelRef } | undefined>; + }; + readonly event?: { + subscribe(options?: { signal?: AbortSignal }): AsyncIterable; }; }; +type SessionEvent = { + readonly type: string; + readonly data?: { readonly sessionID?: string; readonly error?: RetryError }; +}; + const MAX_BACKOFF_MS = 60 * 60 * 1000; +/** Delay before retrying on a freshly selected fallback model. */ +const SWITCH_DELAY_MS = 1000; + const RETRYABLE_ERROR_PATTERNS = [ /\b429\b/i, /rate[\s_-]*limit/i, @@ -74,65 +97,189 @@ function exponentialDelay(baseDelayMs: number, failureCount: number): number { return Math.min(Math.max(MAX_BACKOFF_MS, baseDelayMs), baseDelayMs * 2 ** exponent); } +function sameModel(a: ModelRef | undefined, b: ModelRef | undefined): boolean { + return a !== undefined && b !== undefined && a.providerID === b.providerID && a.id === b.id + && (a.variant ?? "default") === (b.variant ?? "default"); +} + +function toModelRef(model: FallbackModel): ModelRef { + return { providerID: model.providerID, id: model.modelID }; +} + +/** + * The models a session walks through on consecutive failures: its own model, + * then the configured fallbacks listed after it. A model that is not in + * `fallbackModels` has no fallbacks and is simply retried after a cooldown. + */ +function chainFor(model: ModelRef | undefined, fallbacks: readonly ModelRef[]): ModelRef[] { + if (model === undefined) return []; + const index = fallbacks.findIndex((candidate) => sameModel(candidate, model)); + if (index < 0) return [model]; + return [model, ...fallbacks.slice(index + 1).filter((candidate) => !sameModel(candidate, model))]; +} + +/** One run of consecutive failures in a session, ended by a real user prompt. */ +type Streak = { + /** The session's model when the streak began; restored on the next real prompt. */ + original: ModelRef | undefined; + chain: ModelRef[]; + /** Full passes through the chain that ended in failure; drives the backoff. */ + passes: number; + /** The model this plugin last selected, if it switched at all. */ + selected?: ModelRef; +}; + export default { id: "opencode-rate-limit-fallback", async setup(context: V2Context) { const { config } = loadConfig(context.location.directory, context.location.directory); - if (!config.enabled || config.fallbackMode !== "wait") return; + if (!config.enabled) return; + // "wait" never switches models; the other modes walk the fallback chain + // first and only then decide between waiting ("cycle", "retry-last") and + // giving up ("stop"). + const mode = config.fallbackMode; + const fallbacks = mode === "wait" ? [] : (config.fallbackModels ?? []) + .filter((model) => typeof model?.providerID === "string" && typeof model?.modelID === "string") + .map(toModelRef); const cooldownMs = effectiveCooldown(config.cooldownMs); const resumeTimers = new Map>(); - const failureCounts = new Map(); + const streaks = new Map(); - const clearSessionState = (sessionID: string) => { + const cancelResume = (sessionID: string) => { const previous = resumeTimers.get(sessionID); if (previous) { clearTimeout(previous); resumeTimers.delete(sessionID); } - failureCounts.delete(sessionID); }; const scheduleResume = (sessionID: string, delay: number) => { - const previous = resumeTimers.get(sessionID); - if (previous) clearTimeout(previous); + cancelResume(sessionID); const timer = setTimeout(() => { resumeTimers.delete(sessionID); void context.session.prompt({ sessionID, text: "", resume: true }).catch(() => { // A deleted or otherwise unavailable session must not keep retrying. }); - }, delay + 250); + }, delay); resumeTimers.set(sessionID, timer); }; - const promptRegistration = await context.session.hook("prompt", (event) => { - // A real user prompt starts a fresh backoff sequence. The empty prompt used - // to resume after OpenCode's hard retry ceiling must preserve the streak. - if (event.prompt.text.trim().length > 0) clearSessionState(event.sessionID); + const switchModel = async (sessionID: string, model: ModelRef): Promise => { + if (!context.session.switchModel) return false; + try { + await context.session.switchModel({ sessionID, model }); + return true; + } catch { + return false; + } + }; + + const currentModel = async (sessionID: string): Promise => { + try { + return context.session.get ? (await context.session.get({ sessionID }))?.model : undefined; + } catch { + return undefined; + } + }; + + // Put the session back on its own model, unless someone picked another + // model since this plugin switched it. + const restoreModel = async (sessionID: string, streak: Streak) => { + if (streak.selected === undefined || streak.original === undefined) return; + if (sameModel(streak.selected, streak.original)) return; + const current = await currentModel(sessionID); + if (current !== undefined && !sameModel(current, streak.selected)) return; + await switchModel(sessionID, streak.original); + }; + + // `failed` just failed with a retryable error: select the model for the + // next try and say when it is due. + const decide = async (sessionID: string, failed: ModelRef | undefined): Promise => { + let streak = streaks.get(sessionID); + let position = streak?.chain.findIndex((model) => sameModel(model, failed)) ?? -1; + if (!streak || (failed !== undefined && position < 0)) { + // First failure, or the model was changed by someone else mid-streak: + // start over from the model that just failed. + streak = { original: failed, chain: chainFor(failed, fallbacks), passes: streak?.passes ?? 0 }; + streaks.set(sessionID, streak); + position = 0; + } + + let next: ModelRef | undefined = streak.chain[position + 1]; + let delay = SWITCH_DELAY_MS; + if (next !== undefined && !(await switchModel(sessionID, next))) next = undefined; + if (next === undefined) { + // Every model in the chain failed on this pass (or switching is not possible). + if (mode === "stop" && streak.chain.length > 1) return { retry: false }; + streak.passes += 1; + delay = exponentialDelay(cooldownMs, streak.passes); + const first = streak.chain[0]; + if (mode !== "retry-last" && first !== undefined && !sameModel(first, failed)) { + if (await switchModel(sessionID, first)) next = first; + } + } + if (next !== undefined) streak.selected = next; + return { retry: true, delay }; + }; + + const promptRegistration = await context.session.hook("prompt", async (event) => { + // A real user prompt starts a fresh backoff sequence on the session's own + // model. The empty prompt used to resume a failed turn must preserve the + // streak. + if (event.prompt.text.trim().length === 0) return; + cancelResume(event.sessionID); + const streak = streaks.get(event.sessionID); + streaks.delete(event.sessionID); + if (streak) await restoreModel(event.sessionID, streak); }); - const retryRegistration = await context.session.hook("retry", (event) => { + const retryRegistration = await context.session.hook("retry", async (event) => { if (!isRetryableError(event.error)) return; - const previous = resumeTimers.get(event.sessionID); - if (previous) { - clearTimeout(previous); - resumeTimers.delete(event.sessionID); - } - const failureCount = (failureCounts.get(event.sessionID) ?? 0) + 1; - failureCounts.set(event.sessionID, failureCount); - const delay = exponentialDelay(cooldownMs, failureCount); - event.decision = { retry: true, delay }; - // OpenCode v2 currently stops after attempt 5 even when a retry hook - // requests another retry. Resume the same prompt after that hard limit. - if (event.attempt >= 5) scheduleResume(event.sessionID, delay); + cancelResume(event.sessionID); + event.decision = await decide(event.sessionID, event.model); }); + // OpenCode has its own ceiling on retries and fails the turn once it is + // reached, even when this plugin asked for another try. Such a turn is + // resumed when the next try is due. An interrupted turn stays stopped. + const abort = new AbortController(); + const onEvent = async (event: SessionEvent) => { + const sessionID = event.data?.sessionID; + if (typeof sessionID !== "string") return; + if (event.type === "session.deleted") { + cancelResume(sessionID); + streaks.delete(sessionID); + return; + } + if (event.type !== "session.execution.failed") return; + const streak = streaks.get(sessionID); + if (!streak || !isRetryableError(event.data?.error)) return; + const failed = (await currentModel(sessionID)) ?? streak.selected ?? streak.original; + const decision = await decide(sessionID, failed); + if (decision.retry) scheduleResume(sessionID, decision.delay); + }; + const watch = async (events: NonNullable) => { + while (!abort.signal.aborted) { + try { + for await (const event of events.subscribe({ signal: abort.signal })) { + void onEvent(event).catch(() => {}); + } + } catch { + // The stream broke; subscribe again below. + } + if (!abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000)); + } + }; + if (context.event) void watch(context.event); + return async () => { + abort.abort(); await promptRegistration.dispose(); await retryRegistration.dispose(); for (const timer of resumeTimers.values()) clearTimeout(timer); resumeTimers.clear(); - failureCounts.clear(); + streaks.clear(); }; }, }; From 19d00022fbf880a1cfdcad43eb271ff1a4172625 Mon Sep 17 00:00:00 2001 From: azumag Date: Sun, 18 Oct 2026 02:46:59 +0900 Subject: [PATCH 2/2] fix(v2): keep 2.0.x behaviour for models outside the fallback chain - Leave sessions whose model is not in fallbackModels to OpenCode by default, as 2.0.x did; "otherModels": "wait" opts into retrying the same model after a cooldown. - Register nothing for a non-wait config without fallback models, so a default config still does nothing. - Validate otherModels in config and cover the compatibility cases in tests. --- README.md | 12 +++++++--- __tests__/config.test.ts | 7 ++++++ __tests__/v2.test.ts | 51 ++++++++++++++++++++++++++++++++++++++-- src/types/index.ts | 14 +++++++++++ src/utils/config.ts | 3 +++ v2.ts | 13 +++++++--- 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3f7d22d..cce7795 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ OpenCode v2 / Web uses the `plugins` key: ```json { - "plugins": ["@azumag/opencode-rate-limit-fallback@2.0.2"] + "plugins": ["@azumag/opencode-rate-limit-fallback@2.1.0"] } ``` @@ -62,8 +62,12 @@ every model from the session's own model to the end of the chain has failed, | `"stop"` | Let the turn fail | | `"wait"` | Never switch models: retry the session's model after a cooldown | -A session whose model is not in `fallbackModels` is never switched; it is -retried on its own model after a cooldown, as in `"wait"`. +A session whose model is not in `fallbackModels` is never switched. By default +(`"otherModels": "ignore"`) the plugin leaves it to OpenCode's own retries, as +2.0.x did; with `"otherModels": "wait"` it is retried on its own model after a +cooldown, as in `"wait"`. Without fallback models (and without +`"otherModels": "wait"`), the plugin does nothing unless `fallbackMode` is +`"wait"`. The cooldown starts at `cooldownMs` and doubles with every consecutive exhausted pass (`1x`, `2x`, `4x`, ...), capped at one hour. The next user prompt @@ -81,6 +85,7 @@ interrupted (by the user, or by a bot that gave up) is not resumed. "enabled": true, "fallbackMode": "cycle", "cooldownMs": 60000, + "otherModels": "wait", "fallbackModels": [ { "providerID": "opencode", "modelID": "glm-5.3-flash-free" }, { "providerID": "opencode", "modelID": "deepseek-v4.1-flash-free" } @@ -162,6 +167,7 @@ Create a configuration file at one of these locations: | `enabled` | boolean | `true` | Enable/disable the plugin | | `cooldownMs` | number | `60000` | Cooldown period (ms) before retrying a rate-limited model | | `fallbackMode` | string | `"cycle"` | Behavior when all models are exhausted (see below) | + | `otherModels` | string | `"ignore"` | OpenCode v2 only: a session whose model is not in `fallbackModels` is left to OpenCode (`"ignore"`) or retried on its own model after a cooldown (`"wait"`) | | `headlessOnRateLimit` | string | `undefined` | Headless mode behavior on rate limit (see below) | | `fallbackModels` | array | See below | List of fallback models in priority order | | `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies | diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts index 7917a16..77c80e4 100644 --- a/__tests__/config.test.ts +++ b/__tests__/config.test.ts @@ -161,4 +161,11 @@ describe('config utilities', () => { expect(config.maxSubagentDepth).toBe(4); expect(config.enableSubagentFallback).toBe(false); }); + + it('keeps a valid otherModels value and drops an invalid one', () => { + expect(validateConfig({ otherModels: 'wait' }).otherModels).toBe('wait'); + expect(validateConfig({ otherModels: 'ignore' }).otherModels).toBe('ignore'); + expect(validateConfig({ otherModels: 'sleep' } as unknown as Partial).otherModels).toBeUndefined(); + expect(validateConfig({}).otherModels).toBeUndefined(); + }); }); diff --git a/__tests__/v2.test.ts b/__tests__/v2.test.ts index 880e5ea..3a2af1d 100644 --- a/__tests__/v2.test.ts +++ b/__tests__/v2.test.ts @@ -301,12 +301,28 @@ describe("OpenCode v2 plugin", () => { await cleanup?.(); }); + it("registers nothing without a config file, like 2.0.3", async () => { + vi.mocked(existsSync).mockReturnValue(false); + const hook = vi.fn(async () => ({ dispose: vi.fn() })); + const cleanup = await plugin.setup({ location: { directory: "/test" }, session: { hook, prompt: vi.fn() } }); + expect(hook).not.toHaveBeenCalled(); + expect(cleanup).toBeUndefined(); + }); + + it("registers nothing for a cycle config without fallback models, like 2.0.3", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ enabled: true, fallbackMode: "cycle", fallbackModels: [] })); + const hook = vi.fn(async () => ({ dispose: vi.fn() })); + await plugin.setup({ location: { directory: "/test" }, session: { hook, prompt: vi.fn() } }); + expect(hook).not.toHaveBeenCalled(); + }); + describe("model fallback", () => { const GLM = { providerID: "opencode", id: "glm-5.3-flash-free" }; const DEEPSEEK = { providerID: "opencode", id: "deepseek-v4.1-flash-free" }; const OTHER = { providerID: "opencode-go", id: "kimi-k3" }; - async function setupFallback(fallbackMode: string, sessionModel: { providerID: string; id: string } = GLM) { + async function setupFallback(fallbackMode: string, sessionModel: { providerID: string; id: string } = GLM, extra: Record = {}) { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ enabled: true, @@ -316,6 +332,7 @@ describe("OpenCode v2 plugin", () => { { providerID: GLM.providerID, modelID: GLM.id }, { providerID: DEEPSEEK.providerID, modelID: DEEPSEEK.id }, ], + ...extra, })); const hooks: Record Promise | void> = {}; let current = sessionModel; @@ -438,8 +455,31 @@ describe("OpenCode v2 plugin", () => { await t.cleanup?.(); }); - it("waits on the same model when the session's model is not in the chain", async () => { + it("leaves a session whose model is not in the chain to OpenCode by default", async () => { const t = await setupFallback("cycle", OTHER); + expect(await t.fail(2)).toEqual({ retry: true, delay: 2000 }); // OpenCode's own decision, untouched + expect(t.switchModel).not.toHaveBeenCalled(); + // and a turn OpenCode gives up on is not resumed + vi.useFakeTimers(); + t.events.push({ type: "session.execution.failed", data: { sessionID: "session-1", error: { status: 429, message: "Rate limit exceeded" } } }); + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(t.prompt).not.toHaveBeenCalled(); + await t.cleanup?.(); + }); + + it("does not resume a turn that ended on a model outside the chain", async () => { + vi.useFakeTimers(); + const t = await setupFallback("cycle"); + await t.fail(2); // GLM -> DeepSeek: a streak exists + t.get.mockResolvedValue({ model: OTHER }); // then someone picks a model outside the chain + t.events.push({ type: "session.execution.failed", data: { sessionID: "session-1", error: { status: 429, message: "Rate limit exceeded" } } }); + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(t.prompt).not.toHaveBeenCalled(); + await t.cleanup?.(); + }); + + it("otherModels \"wait\": waits on the same model when the session's model is not in the chain", async () => { + const t = await setupFallback("cycle", OTHER, { otherModels: "wait" }); expect(await t.fail(2)).toEqual({ retry: true, delay: 60000 }); expect(await t.fail(3)).toEqual({ retry: true, delay: 120000 }); expect(t.switchModel).not.toHaveBeenCalled(); @@ -447,6 +487,13 @@ describe("OpenCode v2 plugin", () => { await t.cleanup?.(); }); + it("otherModels \"wait\" does not change how chain models fall back", async () => { + const t = await setupFallback("cycle", GLM, { otherModels: "wait" }); + expect(await t.fail(2)).toEqual({ retry: true, delay: 1000 }); + expect(t.model()).toEqual(DEEPSEEK); + await t.cleanup?.(); + }); + it("starts from the session's position in the chain", async () => { const t = await setupFallback("cycle", DEEPSEEK); expect(await t.fail(2)).toEqual({ retry: true, delay: 60000 }); diff --git a/src/types/index.ts b/src/types/index.ts index fa7f36c..d4a709e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,14 @@ export interface FallbackModel { */ export type FallbackMode = "cycle" | "stop" | "retry-last" | "wait"; +/** + * OpenCode v2: what happens to a session whose model is not in + * fallbackModels (with a fallbackMode other than "wait"). "ignore" leaves the + * failure to OpenCode's own retries; "wait" retries the same model after a + * cooldown, as in "wait" mode. + */ +export type OtherModelsMode = "ignore" | "wait"; + /** * Headless mode behavior on rate limit: * - "ignore": Do nothing, let server handle retries (default) @@ -310,6 +318,7 @@ export interface PluginConfig { cooldownMs: number; enabled: boolean; fallbackMode: FallbackMode; + otherModels?: OtherModelsMode; headlessOnRateLimit?: HeadlessOnRateLimit; maxSubagentDepth?: number; enableSubagentFallback?: boolean; @@ -660,6 +669,11 @@ export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = { */ export const VALID_FALLBACK_MODES: FallbackMode[] = ["cycle", "stop", "retry-last", "wait"]; +/** + * Valid otherModels options (OpenCode v2) + */ +export const VALID_OTHER_MODELS: OtherModelsMode[] = ["ignore", "wait"]; + /** * Valid headless on rate limit options */ diff --git a/src/utils/config.ts b/src/utils/config.ts index 76b6107..e9e54b3 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -10,6 +10,7 @@ import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_HEADLESS_ON_RATE_LIMIT, + VALID_OTHER_MODELS, VALID_RESET_INTERVALS, DEFAULT_RETRY_POLICY, VALID_RETRY_STRATEGIES, @@ -97,6 +98,7 @@ function isValidIgnorePattern(value: unknown): value is string | RegExp { export function validateConfig(config: Partial): PluginConfig { const mode = config.fallbackMode; const headlessOnRateLimit = config.headlessOnRateLimit; + const otherModels = config.otherModels; const resetInterval = config.metrics?.resetInterval; const strategy = config.retryPolicy?.strategy; const maxSubagentDepth = config.maxSubagentDepth; @@ -141,6 +143,7 @@ export function validateConfig(config: Partial): PluginConfig { fallbackModels: Array.isArray(config.fallbackModels) ? config.fallbackModels : DEFAULT_CONFIG.fallbackModels, fallbackMode: mode && VALID_FALLBACK_MODES.includes(mode) ? mode : DEFAULT_CONFIG.fallbackMode, headlessOnRateLimit: headlessOnRateLimit && VALID_HEADLESS_ON_RATE_LIMIT.includes(headlessOnRateLimit) ? headlessOnRateLimit : undefined, + otherModels: otherModels && VALID_OTHER_MODELS.includes(otherModels) ? otherModels : undefined, maxSubagentDepth: Number.isInteger(maxSubagentDepth) && (maxSubagentDepth ?? 0) > 0 ? maxSubagentDepth : DEFAULT_CONFIG.maxSubagentDepth, diff --git a/v2.ts b/v2.ts index d64ec0d..01692d0 100644 --- a/v2.ts +++ b/v2.ts @@ -109,7 +109,8 @@ function toModelRef(model: FallbackModel): ModelRef { /** * The models a session walks through on consecutive failures: its own model, * then the configured fallbacks listed after it. A model that is not in - * `fallbackModels` has no fallbacks and is simply retried after a cooldown. + * `fallbackModels` has no fallbacks: it is retried after a cooldown when + * otherModels is "wait" (or in "wait" mode), and otherwise never gets here. */ function chainFor(model: ModelRef | undefined, fallbacks: readonly ModelRef[]): ModelRef[] { if (model === undefined) return []; @@ -137,11 +138,16 @@ export default { // "wait" never switches models; the other modes walk the fallback chain // first and only then decide between waiting ("cycle", "retry-last") and - // giving up ("stop"). + // giving up ("stop"). A session whose model is not in the chain is left + // to OpenCode unless otherModels is "wait". const mode = config.fallbackMode; + const otherModels = config.otherModels ?? "ignore"; const fallbacks = mode === "wait" ? [] : (config.fallbackModels ?? []) .filter((model) => typeof model?.providerID === "string" && typeof model?.modelID === "string") .map(toModelRef); + if (mode !== "wait" && fallbacks.length === 0 && otherModels !== "wait") return; + const handles = (model: ModelRef | undefined) => + mode === "wait" || otherModels === "wait" || fallbacks.some((candidate) => sameModel(candidate, model)); const cooldownMs = effectiveCooldown(config.cooldownMs); const resumeTimers = new Map>(); const streaks = new Map(); @@ -235,7 +241,7 @@ export default { }); const retryRegistration = await context.session.hook("retry", async (event) => { - if (!isRetryableError(event.error)) return; + if (!isRetryableError(event.error) || !handles(event.model)) return; cancelResume(event.sessionID); event.decision = await decide(event.sessionID, event.model); }); @@ -256,6 +262,7 @@ export default { const streak = streaks.get(sessionID); if (!streak || !isRetryableError(event.data?.error)) return; const failed = (await currentModel(sessionID)) ?? streak.selected ?? streak.original; + if (!handles(failed)) return; const decision = await decide(sessionID, failed); if (decision.retry) scheduleResume(sessionID, decision.delay); };