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.
This commit is contained in:
YeonGyu-Kim
2026-02-22 17:25:04 +09:00
parent 2e845c8d99
commit 9933c6654f
4 changed files with 75 additions and 7 deletions

View File

@@ -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(),

View File

@@ -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([])
})
})

View File

@@ -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<string, string>();
const lastHandledRetryStatusKey = new Map<string, string>();
@@ -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()) {

View File

@@ -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 }) => {