From 9933c6654f96ba1907560164fced5221880b7fb8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 22 Feb 2026 17:25:04 +0900 Subject: [PATCH] feat(model-fallback): disable model fallback retry by default Model fallback is now opt-in via `model_fallback: true` in plugin config, matching the runtime-fallback pattern. Prevents unexpected automatic model switching on API errors unless explicitly enabled. --- src/config/schema/oh-my-opencode-config.ts | 2 + src/plugin/event.model-fallback.test.ts | 66 +++++++++++++++++++++- src/plugin/event.ts | 9 ++- src/plugin/hooks/create-session-hooks.ts | 5 +- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 2517f597..b36e8688 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -35,6 +35,8 @@ export const OhMyOpenCodeConfigSchema = z.object({ disabled_tools: z.array(z.string()).optional(), /** Enable hashline_edit tool/hook integrations (default: true at call site) */ hashline_edit: z.boolean().optional(), + /** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */ + model_fallback: z.boolean().optional(), agents: AgentOverridesSchema.optional(), categories: CategoriesConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index 68aa1c54..aa01e48f 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -53,7 +53,8 @@ describe("createEventHandler - model fallback", () => { test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => { //#given const sessionID = "ses_message_updated_fallback" - const { handler, abortCalls, promptCalls } = createHandler() + const modelFallback = createModelFallbackHook() + const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } }) //#when await handler({ @@ -95,7 +96,8 @@ describe("createEventHandler - model fallback", () => { //#given const sessionID = "ses_main_fallback_nested" setMainSession(sessionID) - const { handler, abortCalls, promptCalls } = createHandler() + const modelFallback = createModelFallbackHook() + const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } }) //#when await handler({ @@ -340,4 +342,64 @@ describe("createEventHandler - model fallback", () => { expect(promptCalls).toEqual([sessionID, sessionID]) expect(toastCalls.length).toBeGreaterThanOrEqual(0) }) + + test("does not trigger model-fallback retry when modelFallback hook is not provided (disabled by default)", async () => { + //#given + const sessionID = "ses_disabled_by_default" + setMainSession(sessionID) + const { handler, abortCalls, promptCalls } = createHandler() + + //#when - message.updated with assistant error + await handler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_err_disabled_1", + sessionID, + role: "assistant", + time: { created: 1, completed: 2 }, + error: { + name: "APIError", + data: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}", + isRetryable: true, + }, + }, + parentID: "msg_user_disabled_1", + modelID: "claude-opus-4-6-thinking", + providerID: "anthropic", + agent: "Sisyphus (Ultraworker)", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }, + }, + }) + + //#when - session.error with retryable error + await handler({ + event: { + type: "session.error", + properties: { + sessionID, + error: { + name: "UnknownError", + data: { + error: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}", + }, + }, + }, + }, + }, + }) + + //#then - no abort or prompt calls should have been made + expect(abortCalls).toEqual([]) + expect(promptCalls).toEqual([]) + }) }) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 04e3f602..b8f7910a 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -126,6 +126,9 @@ export function createEventHandler(args: { ? args.pluginConfig.runtime_fallback : (args.pluginConfig.runtime_fallback?.enabled ?? false)); + const isModelFallbackEnabled = + hooks.modelFallback !== null && hooks.modelFallback !== undefined; + // Avoid triggering multiple abort+continue cycles for the same failing assistant message. const lastHandledModelErrorMessageID = new Map(); const lastHandledRetryStatusKey = new Map(); @@ -271,7 +274,7 @@ export function createEventHandler(args: { // Model fallback: in practice, API/model failures often surface as assistant message errors. // session.error events are not guaranteed for all providers, so we also observe message.updated. - if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) { + if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled && isModelFallbackEnabled) { try { const assistantMessageID = info?.id as string | undefined; const assistantError = info?.error; @@ -334,7 +337,7 @@ export function createEventHandler(args: { const sessionID = props?.sessionID as string | undefined; const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined; - if (sessionID && status?.type === "retry") { + if (sessionID && status?.type === "retry" && isModelFallbackEnabled) { try { const retryMessage = typeof status.message === "string" ? status.message : ""; const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`; @@ -422,7 +425,7 @@ export function createEventHandler(args: { } } // Second, try model fallback for model errors (rate limit, quota, provider issues, etc.) - else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) { + else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) { let agentName = getSessionAgent(sessionID); if (!agentName && sessionID === getMainSessionID()) { diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 78d53eb5..411d0fc3 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -151,9 +151,10 @@ export function createSessionHooks(args: { } } - // Model fallback hook (configurable via disabled_hooks) + // Model fallback hook (configurable via model_fallback config + disabled_hooks) // This handles automatic model switching when model errors occur - const modelFallback = isHookEnabled("model-fallback") + const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false + const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback") ? safeHook("model-fallback", () => createModelFallbackHook({ toast: async ({ title, message, variant, duration }) => {