diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 2d46ae39fa2f..e2e59fc2e6ba 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -1,25 +1,3 @@ -import { Bus } from "@/bus" -import { Config } from "@/config/config" -import { Flag } from "@/flag/flag" -import { Installation } from "@/installation" +// Auto-update disabled in custom build -export async function upgrade() { - const config = await Config.global() - const method = await Installation.method() - const latest = await Installation.latest(method).catch(() => {}) - if (!latest) return - if (Installation.VERSION === latest) return - - if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) { - return - } - if (config.autoupdate === "notify") { - await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) - return - } - - if (method === "unknown") return - await Installation.upgrade(method, latest) - .then(() => Bus.publish(Installation.Event.Updated, { version: latest })) - .catch(() => {}) -} +export async function upgrade() {} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 27ba4e186712..2cb271e28c53 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1222,6 +1222,21 @@ export namespace Config { .describe("Timeout in milliseconds for model context protocol (MCP) requests"), }) .optional(), + session: z + .object({ + retry: z + .object({ + enabled: z.boolean().optional().describe("Enable native retry logic (default: true)"), + delegate_to_plugin: z + .boolean() + .optional() + .describe("Delegate retry logic to active plugins (e.g., oh-my-opencode). When true, native retry is disabled."), + }) + .optional() + .describe("Session retry configuration"), + }) + .optional() + .describe("Session configuration"), }) .strict() .meta({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 38dac41b0589..c29a150563cd 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -364,8 +364,13 @@ export namespace SessionProcessor { error, }) } else { + // Check if retry should be delegated to plugins + const config = await Config.get() + const delegateRetryToPlugin = config.session?.retry?.delegate_to_plugin === true + const nativeRetryEnabled = config.session?.retry?.enabled !== false + const retry = SessionRetry.retryable(error) - if (retry !== undefined) { + if (retry !== undefined && nativeRetryEnabled && !delegateRetryToPlugin) { attempt++ const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) SessionStatus.set(input.sessionID, { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 6d057f539f81..11d195b995d2 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -61,25 +61,44 @@ export namespace SessionRetry { export function retryable(error: ReturnType) { // context overflow errors should not be retried if (MessageV2.ContextOverflowError.isInstance(error)) return undefined + if (MessageV2.APIError.isInstance(error)) { - if (!error.data.isRetryable) return undefined + // Check isRetryable at both top level and nested level + const isRetryable = error.data.isRetryable ?? true + if (!isRetryable) return undefined + if (error.data.responseBody?.includes("FreeUsageLimitError")) return `Free usage exceeded, add credits https://opencode.ai/zen` return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } + // Handle errors with nested structure (data.error.message) const json = iife(() => { try { + // Try top-level data.message first if (typeof error.data?.message === "string") { const parsed = JSON.parse(error.data.message) return parsed } - return JSON.parse(error.data.message) + // Try nested data.error.message + if (typeof (error.data as any)?.error?.message === "string") { + const parsed = JSON.parse((error.data as any).error.message) + return parsed + } + + // Try parsing responseBody directly + if (typeof error.data?.responseBody === "string") { + const parsed = JSON.parse(error.data.responseBody) + return parsed + } + + return undefined } catch { return undefined } }) + try { if (!json || typeof json !== "object") return undefined const code = typeof json.code === "string" ? json.code : "" @@ -93,6 +112,10 @@ export namespace SessionRetry { if (json.type === "error" && json.error?.code?.includes("rate_limit")) { return "Rate Limited" } + // Also check for rate_limit_error type + if (json.error?.type === "rate_limit_error") { + return json.error.message || "Rate Limited" + } return JSON.stringify(json) } catch { return undefined diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 621ad99e9b47..4b1dcae95acc 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -190,3 +190,49 @@ describe("session.message-v2.fromError", () => { expect(result.data.isRetryable).toBe(true) }) }) + +describe("session.retry.retryable nested error structures", () => { + test("recognizes Anthropic rate limit error with responseBody", () => { + const error = new MessageV2.APIError({ + message: "This request would exceed your account's rate limit. Please try again later.", + isRetryable: true, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "rate_limit_error", + message: "This request would exceed your account's rate limit. Please try again later." + } + }) + }).toObject() + + const result = SessionRetry.retryable(error) + expect(result).toBeDefined() + expect(result).toContain("rate limit") + }) + + test("recognizes rate limit error with type in responseBody", () => { + const error = new MessageV2.APIError({ + message: "Rate limit reached", + isRetryable: true, + responseBody: JSON.stringify({ + error: { + type: "too_many_requests" + } + }) + }).toObject() + + const result = SessionRetry.retryable(error) + expect(result).toBeDefined() + }) + + test("handles isRetryable undefined as retryable", () => { + const error = new MessageV2.APIError({ + message: "Some error", + isRetryable: undefined as any, + }).toObject() + + const result = SessionRetry.retryable(error) + // When isRetryable is undefined, it defaults to true + expect(result).toBeDefined() + }) +})