diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5b227ec..650b7ca 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -76,4 +76,12 @@ jobs: repository: ${{ github.repository }} run-id: ${{ github.event.workflow_run.id }} - - run: npm publish --access public --ignore-scripts + - name: Publish package + run: | + VERSION=$(node -p "require('./package.json').version") + MAJOR=${VERSION%%.*} + if [ "$MAJOR" -ge 2 ]; then + npm publish --access public --ignore-scripts --tag v2 + else + npm publish --access public --ignore-scripts + fi diff --git a/README.md b/README.md index 908b3cc..0d41850 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,28 @@ OpenCode plugin that automatically switches to fallback models when rate limited ## Installation -Add the plugin to your `opencode.json`: +OpenCode v2 / Web uses the `plugins` key: ```json { - "plugin": ["@azumag/opencode-rate-limit-fallback"] + "plugins": ["@azumag/opencode-rate-limit-fallback@2.0.0"] } ``` +OpenCode v1 must stay on the latest 1.x release: + +```json +{ + "plugin": ["@azumag/opencode-rate-limit-fallback@1.70.11"] +} +``` + +Version 2 is published under the `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. + 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 new file mode 100644 index 0000000..83966a6 --- /dev/null +++ b/__tests__/v2.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import plugin from "../v2.js"; +import { existsSync, readFileSync } from "fs"; + +vi.mock("fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +describe("OpenCode v2 plugin", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("exports the v2 definition shape", () => { + expect(plugin.id).toBe("opencode-rate-limit-fallback"); + expect(plugin.setup).toBeTypeOf("function"); + }); + + it("overrides rate-limit retries with the configured cooldown", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, + fallbackMode: "wait", + cooldownMs: 60000, + fallbackModels: [], + })); + let retryHook: ((event: any) => void) | undefined; + const dispose = vi.fn(); + const cleanup = await plugin.setup({ + location: { directory: "/test" }, + session: { + hook: vi.fn(async (_name, callback) => { + retryHook = callback; + return { dispose }; + }), + prompt: vi.fn(), + }, + }); + + const event = { + sessionID: "session-1", + attempt: 1, + error: { type: "provider", message: "Rate limit exceeded", status: 429 }, + decision: { retry: false }, + }; + retryHook?.(event); + expect(event.decision).toEqual({ retry: true, delay: 60000 }); + + await cleanup?.(); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("resumes a terminal rate-limit failure after cooldown", 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) => void) | undefined; + const prompt = vi.fn().mockResolvedValue(undefined); + const cleanup = await plugin.setup({ + location: { directory: "/test" }, + session: { + hook: vi.fn(async (_name, callback) => { + retryHook = callback; + return { dispose: vi.fn() }; + }), + prompt, + }, + }); + + retryHook?.({ + sessionID: "session-1", + attempt: 5, + error: { status: 429, message: "Rate limit exceeded" }, + decision: { retry: false }, + }); + await vi.advanceTimersByTimeAsync(1250); + + expect(prompt).toHaveBeenCalledWith({ sessionID: "session-1", text: "", resume: true }); + + await cleanup?.(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 32574e3..2f484a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,16 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "1.70.11", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azumag/opencode-rate-limit-fallback", - "version": "1.70.11", + "version": "2.0.0", "license": "MIT", - "dependencies": { - "@opencode-ai/plugin": "1.18.16", - "@opencode-ai/sdk": "1.18.16" - }, "devDependencies": { + "@opencode-ai/plugin": "1.18.16", + "@opencode-ai/sdk": "1.18.16", "@types/node": "^25.2.2", "@vitest/coverage-v8": "^4.1.10", "typescript": "^5.3.0", @@ -23,6 +21,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -126,6 +125,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -139,6 +139,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -152,6 +153,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -165,6 +167,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -178,6 +181,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -191,6 +195,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -201,6 +206,7 @@ "version": "1.18.16", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.16.tgz", "integrity": "sha512-XvlZ9CFAXqrNrmpTe+EhhRYnDhca6db1ebcB10v5JO8kSm1iFbVbPP982Qzc4MRSrfpPjD8W1fg13Ubnff377g==", + "dev": true, "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", @@ -229,6 +235,7 @@ "version": "1.18.16", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.16.tgz", "integrity": "sha512-UvaHyL93spLm7MttoprWTOBSYQN3aYWd7/3Ie5WNnG2pRAPq/U/d51qt0smYBTrkjxqlQmg+AJoGw8MEH6lGUg==", + "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -511,6 +518,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@types/chai": { @@ -735,6 +743,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -749,7 +758,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -759,6 +768,7 @@ "version": "4.0.0-beta.83", "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -804,6 +814,7 @@ "version": "4.9.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, "funding": [ { "type": "individual", @@ -844,6 +855,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, "license": "MIT" }, "node_modules/fsevents": { @@ -882,6 +894,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, "license": "ISC", "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" @@ -891,6 +904,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -943,12 +957,14 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, "license": "Apache-2.0" }, "node_modules/lightningcss": { @@ -1266,6 +1282,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "dev": true, "license": "MIT", "optionalDependencies": { "msgpackr-extract": "^3.0.4" @@ -1275,6 +1292,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1297,6 +1315,7 @@ "version": "0.2.8", "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1322,6 +1341,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1351,6 +1371,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1416,6 +1437,7 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, "funding": [ { "type": "individual", @@ -1478,6 +1500,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -1490,6 +1513,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1587,6 +1611,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -1615,6 +1640,7 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -1796,6 +1822,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -1828,6 +1855,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -1841,6 +1869,7 @@ }, "node_modules/zod": { "version": "4.1.8", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 0a6c7b8..c6babd2 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,15 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "1.70.11", + "version": "2.0.0", "description": "OpenCode plugin that automatically switches to fallback models when rate limited", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist/v2.js", + "types": "dist/v2.d.ts", + "exports": { + ".": { + "import": "./dist/v2.js", + "types": "./dist/v2.d.ts" + } + }, "type": "module", "keywords": [ "opencode", @@ -38,13 +44,12 @@ "LICENSE" ], "devDependencies": { + "@opencode-ai/plugin": "1.18.16", + "@opencode-ai/sdk": "1.18.16", "@types/node": "^25.2.2", "@vitest/coverage-v8": "^4.1.10", "typescript": "^5.3.0", "vitest": "^4.1.10" }, - "dependencies": { - "@opencode-ai/plugin": "1.18.16", - "@opencode-ai/sdk": "1.18.16" - } + "dependencies": {} } diff --git a/tsconfig.json b/tsconfig.json index b3acd98..570d846 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ }, "include": [ "index.ts", + "v2.ts", "logger.ts", "src/**/*.ts" ], diff --git a/v2.ts b/v2.ts new file mode 100644 index 0000000..2c3180f --- /dev/null +++ b/v2.ts @@ -0,0 +1,95 @@ +import { loadConfig } from "./src/utils/config.js"; + +type RetryError = { + readonly type?: string; + readonly message?: string; + readonly status?: number; +}; + +type RetryEvent = { + readonly sessionID: string; + readonly error: RetryError; + readonly attempt: number; + decision: { retry: false } | { retry: true; delay: number }; +}; + +type Registration = { readonly dispose: () => Promise | void }; + +type V2Context = { + readonly location: { readonly directory: string }; + readonly session: { + hook( + name: "retry", + callback: (event: RetryEvent) => Promise | void, + ): Promise; + prompt(input: { + sessionID: string; + text: string; + resume: boolean; + }): Promise; + }; +}; + +const RATE_LIMIT_PATTERNS = [ + /\b429\b/i, + /rate[\s_-]*limit/i, + /too many requests/i, + /quota/i, + /usage limit/i, + /resource[\s_-]*exhausted/i, + /try again later/i, +]; + +function isRateLimit(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const error = value as RetryError; + if (error.status === 429) return true; + const text = `${error.type ?? ""} ${error.message ?? ""}`; + return RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(text)); +} + +function effectiveCooldown(cooldownMs: number | undefined): number { + return Math.max(1000, cooldownMs ?? 60000); +} + +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; + + const cooldownMs = effectiveCooldown(config.cooldownMs); + const resumeTimers = new Map>(); + + const scheduleResume = (sessionID: string) => { + const previous = resumeTimers.get(sessionID); + if (previous) clearTimeout(previous); + 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. + }); + }, cooldownMs + 250); + resumeTimers.set(sessionID, timer); + }; + + const retryRegistration = await context.session.hook("retry", (event) => { + if (!isRateLimit(event.error)) return; + const previous = resumeTimers.get(event.sessionID); + if (previous) { + clearTimeout(previous); + resumeTimers.delete(event.sessionID); + } + event.decision = { retry: true, delay: cooldownMs }; + // 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); + }); + + return async () => { + await retryRegistration.dispose(); + for (const timer of resumeTimers.values()) clearTimeout(timer); + resumeTimers.clear(); + }; + }, +};