diff --git a/README.md b/README.md index a5abf6d..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"] } ``` @@ -49,14 +49,50 @@ 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. 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 +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, + "otherModels": "wait", + "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 @@ -131,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 35ad1fc..3a2af1d 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,302 @@ 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?.(); }); + + 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, extra: Record = {}) { + 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 }, + ], + ...extra, + })); + 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("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(); + expect(t.model()).toEqual(OTHER); + 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 }); + 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/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 74121be..01692d0 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,196 @@ 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: 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 []; + 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"). 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 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 retryRegistration = await context.session.hook("retry", (event) => { - if (!isRetryableError(event.error)) return; - const previous = resumeTimers.get(event.sessionID); - if (previous) { - clearTimeout(previous); - resumeTimers.delete(event.sessionID); + 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; + } } - 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); + 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", async (event) => { + if (!isRetryableError(event.error) || !handles(event.model)) return; + 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; + if (!handles(failed)) return; + 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(); }; }, };