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:
parent
2e845c8d99
commit
9933c6654f
@ -35,6 +35,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
|||||||
disabled_tools: z.array(z.string()).optional(),
|
disabled_tools: z.array(z.string()).optional(),
|
||||||
/** Enable hashline_edit tool/hook integrations (default: true at call site) */
|
/** Enable hashline_edit tool/hook integrations (default: true at call site) */
|
||||||
hashline_edit: z.boolean().optional(),
|
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(),
|
agents: AgentOverridesSchema.optional(),
|
||||||
categories: CategoriesConfigSchema.optional(),
|
categories: CategoriesConfigSchema.optional(),
|
||||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||||
|
|||||||
@ -53,7 +53,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
|
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_message_updated_fallback"
|
const sessionID = "ses_message_updated_fallback"
|
||||||
const { handler, abortCalls, promptCalls } = createHandler()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
await handler({
|
await handler({
|
||||||
@ -95,7 +96,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_main_fallback_nested"
|
const sessionID = "ses_main_fallback_nested"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
const { handler, abortCalls, promptCalls } = createHandler()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
await handler({
|
await handler({
|
||||||
@ -340,4 +342,64 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
expect(promptCalls).toEqual([sessionID, sessionID])
|
expect(promptCalls).toEqual([sessionID, sessionID])
|
||||||
expect(toastCalls.length).toBeGreaterThanOrEqual(0)
|
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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -126,6 +126,9 @@ export function createEventHandler(args: {
|
|||||||
? args.pluginConfig.runtime_fallback
|
? args.pluginConfig.runtime_fallback
|
||||||
: (args.pluginConfig.runtime_fallback?.enabled ?? false));
|
: (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.
|
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
||||||
const lastHandledModelErrorMessageID = new Map<string, string>();
|
const lastHandledModelErrorMessageID = new Map<string, string>();
|
||||||
const lastHandledRetryStatusKey = 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.
|
// 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.
|
// 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 {
|
try {
|
||||||
const assistantMessageID = info?.id as string | undefined;
|
const assistantMessageID = info?.id as string | undefined;
|
||||||
const assistantError = info?.error;
|
const assistantError = info?.error;
|
||||||
@ -334,7 +337,7 @@ export function createEventHandler(args: {
|
|||||||
const sessionID = props?.sessionID as string | undefined;
|
const sessionID = props?.sessionID as string | undefined;
|
||||||
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | 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 {
|
try {
|
||||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
|
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.)
|
// 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);
|
let agentName = getSessionAgent(sessionID);
|
||||||
|
|
||||||
if (!agentName && sessionID === getMainSessionID()) {
|
if (!agentName && sessionID === getMainSessionID()) {
|
||||||
|
|||||||
@ -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
|
// 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", () =>
|
? safeHook("model-fallback", () =>
|
||||||
createModelFallbackHook({
|
createModelFallbackHook({
|
||||||
toast: async ({ title, message, variant, duration }) => {
|
toast: async ({ title, message, variant, duration }) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user