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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ OpenCode v2 / Web uses the `plugins` key:

```json
{
"plugins": ["@azumag/opencode-rate-limit-fallback@2.0.1"]
"plugins": ["@azumag/opencode-rate-limit-fallback@2.0.2"]
}
```

Expand All @@ -52,6 +52,11 @@ 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
preserve the existing backoff sequence.

OpenCode will automatically install the plugin on startup.

This release is tested with OpenCode `1.18.16`. Older OpenCode releases may
Expand Down
79 changes: 71 additions & 8 deletions __tests__/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe("OpenCode v2 plugin", () => {
expect(plugin.setup).toBeTypeOf("function");
});

it("overrides rate-limit retries with the configured cooldown", async () => {
it("uses the configured cooldown as the base of exponential backoff", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
enabled: true,
Expand All @@ -27,13 +27,14 @@ describe("OpenCode v2 plugin", () => {
fallbackModels: [],
}));
let retryHook: ((event: any) => void) | undefined;
const dispose = vi.fn();
const promptDispose = vi.fn();
const retryDispose = vi.fn();
const cleanup = await plugin.setup({
location: { directory: "/test" },
session: {
hook: vi.fn(async (_name, callback) => {
retryHook = callback;
return { dispose };
hook: vi.fn(async (name, callback) => {
if (name === "retry") retryHook = callback;
return { dispose: name === "retry" ? retryDispose : promptDispose };
}),
prompt: vi.fn(),
},
Expand All @@ -48,8 +49,70 @@ describe("OpenCode v2 plugin", () => {
retryHook?.(event);
expect(event.decision).toEqual({ retry: true, delay: 60000 });

const secondEvent = { ...event, attempt: 2, decision: { retry: false } };
retryHook?.(secondEvent);
expect(secondEvent.decision).toEqual({ retry: true, delay: 120000 });

await cleanup?.();
expect(promptDispose).toHaveBeenCalledOnce();
expect(retryDispose).toHaveBeenCalledOnce();
});

it("caps backoff at one hour and resets it for a new user prompt", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
enabled: true,
fallbackMode: "wait",
cooldownMs: 60000,
fallbackModels: [],
}));
let promptHook: ((event: any) => void) | undefined;
let retryHook: ((event: any) => void) | undefined;
const cleanup = await plugin.setup({
location: { directory: "/test" },
session: {
hook: vi.fn(async (name, callback) => {
if (name === "prompt") promptHook = callback;
if (name === "retry") retryHook = callback;
return { dispose: vi.fn() };
}),
prompt: vi.fn(),
},
});

let event: any;
for (let attempt = 1; attempt <= 8; attempt += 1) {
event = {
sessionID: "session-1",
attempt,
error: { status: 429, message: "Rate limit exceeded" },
decision: { retry: false },
};
retryHook?.(event);
}
expect(event.decision).toEqual({ retry: true, delay: 3600000 });

promptHook?.({ sessionID: "session-1", prompt: { text: "" } });
event = {
sessionID: "session-1",
attempt: 1,
error: { status: 429, message: "Rate limit exceeded" },
decision: { retry: false },
};
retryHook?.(event);
expect(event.decision).toEqual({ retry: true, delay: 3600000 });

promptHook?.({ sessionID: "session-1", prompt: { text: "new request" } });
event = {
sessionID: "session-1",
attempt: 1,
error: { status: 429, message: "Rate limit exceeded" },
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 () => {
Expand All @@ -66,8 +129,8 @@ describe("OpenCode v2 plugin", () => {
const cleanup = await plugin.setup({
location: { directory: "/test" },
session: {
hook: vi.fn(async (_name, callback) => {
retryHook = callback;
hook: vi.fn(async (name, callback) => {
if (name === "retry") retryHook = callback;
return { dispose: vi.fn() };
}),
prompt,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azumag/opencode-rate-limit-fallback",
"version": "2.0.1",
"version": "2.0.2",
"description": "OpenCode plugin that automatically switches to fallback models when rate limited",
"main": "dist/v2.js",
"types": "dist/v2.d.ts",
Expand Down
45 changes: 41 additions & 4 deletions v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ type RetryEvent = {
decision: { retry: false } | { retry: true; delay: number };
};

type PromptEvent = {
readonly sessionID: string;
readonly prompt: { readonly text: string };
};

type Registration = { readonly dispose: () => Promise<void> | void };

type V2Context = {
readonly location: { readonly directory: string };
readonly session: {
hook(
name: "prompt",
callback: (event: PromptEvent) => Promise<void> | void,
): Promise<Registration>;
hook(
name: "retry",
callback: (event: RetryEvent) => Promise<void> | void,
Expand All @@ -30,6 +39,8 @@ type V2Context = {
};
};

const MAX_BACKOFF_MS = 60 * 60 * 1000;

const RATE_LIMIT_PATTERNS = [
/\b429\b/i,
/rate[\s_-]*limit/i,
Expand All @@ -52,6 +63,11 @@ function effectiveCooldown(cooldownMs: number | undefined): number {
return Math.max(1000, cooldownMs ?? 60000);
}

function exponentialDelay(baseDelayMs: number, failureCount: number): number {
const exponent = Math.min(Math.max(0, failureCount - 1), 52);
return Math.min(Math.max(MAX_BACKOFF_MS, baseDelayMs), baseDelayMs * 2 ** exponent);
}

export default {
id: "opencode-rate-limit-fallback",
async setup(context: V2Context) {
Expand All @@ -60,36 +76,57 @@ export default {

const cooldownMs = effectiveCooldown(config.cooldownMs);
const resumeTimers = new Map<string, ReturnType<typeof setTimeout>>();
const failureCounts = new Map<string, number>();

const scheduleResume = (sessionID: string) => {
const clearSessionState = (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);
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);
}, delay + 250);
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 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 };
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);
if (event.attempt >= 5) scheduleResume(event.sessionID, delay);
});

return async () => {
await promptRegistration.dispose();
await retryRegistration.dispose();
for (const timer of resumeTimers.values()) clearTimeout(timer);
resumeTimers.clear();
failureCounts.clear();
};
},
};