Compare commits

...

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
fd7faff792 fix: expand error classifier patterns and auto-enable model_fallback (#2393) 2026-03-12 01:18:15 +09:00
6 changed files with 288 additions and 91 deletions

View File

@@ -0,0 +1,131 @@
import type { OhMyOpenCodeConfig, HookName } from "../../config"
import { createModelFallbackHook } from "../../hooks"
import { normalizeSDKResponse } from "../../shared"
import { resolveModelFallbackEnabled } from "./model-fallback-config"
type SafeHook = <THook>(hookName: HookName, factory: () => THook) => THook | null
type ModelFallbackSessionContext = {
directory: string
client: {
session: {
get: (input: { path: { id: string } }) => Promise<unknown>
update: (input: {
path: { id: string }
body: { title: string }
query: { directory: string }
}) => Promise<unknown>
}
tui: {
showToast: (input: {
body: {
title: string
message: string
variant: "success" | "error" | "info" | "warning"
duration: number
}
}) => Promise<unknown>
}
}
}
function createFallbackTitleUpdater(
ctx: ModelFallbackSessionContext,
enabled: boolean,
):
| ((input: {
sessionID: string
providerID: string
modelID: string
variant?: string
}) => Promise<void>)
| undefined {
if (!enabled) {
return undefined
}
const fallbackTitleMaxEntries = 200
const fallbackTitleState = new Map<string, { baseTitle?: string; lastKey?: string }>()
return async (input) => {
const key = `${input.providerID}/${input.modelID}${input.variant ? `:${input.variant}` : ""}`
const existing = fallbackTitleState.get(input.sessionID) ?? {}
if (existing.lastKey === key) {
return
}
if (!existing.baseTitle) {
const sessionResp = await ctx.client.session.get({ path: { id: input.sessionID } }).catch(() => null)
const sessionInfo = sessionResp
? normalizeSDKResponse(sessionResp, null as { title?: string } | null, {
preferResponseOnMissingData: true,
})
: null
const rawTitle = sessionInfo?.title
if (typeof rawTitle === "string" && rawTitle.length > 0) {
existing.baseTitle = rawTitle.replace(/\s*\[fallback:[^\]]+\]$/i, "").trim()
} else {
existing.baseTitle = "Session"
}
}
const variantLabel = input.variant ? ` ${input.variant}` : ""
const newTitle = `${existing.baseTitle} [fallback: ${input.providerID}/${input.modelID}${variantLabel}]`
await ctx.client.session
.update({
path: { id: input.sessionID },
body: { title: newTitle },
query: { directory: ctx.directory },
})
.catch(() => {})
existing.lastKey = key
fallbackTitleState.set(input.sessionID, existing)
if (fallbackTitleState.size > fallbackTitleMaxEntries) {
const oldestKey = fallbackTitleState.keys().next().value
if (oldestKey) {
fallbackTitleState.delete(oldestKey)
}
}
}
}
export function createConfiguredModelFallbackHook(args: {
ctx: ModelFallbackSessionContext
pluginConfig: OhMyOpenCodeConfig
isHookEnabled: (hookName: HookName) => boolean
safeHook: SafeHook
}): ReturnType<typeof createModelFallbackHook> | null {
const { ctx, pluginConfig, isHookEnabled, safeHook } = args
const isModelFallbackEnabled = resolveModelFallbackEnabled(pluginConfig)
if (!isModelFallbackEnabled || !isHookEnabled("model-fallback")) {
return null
}
const onApplied = createFallbackTitleUpdater(
ctx,
pluginConfig.experimental?.model_fallback_title ?? false,
)
return safeHook("model-fallback", () =>
createModelFallbackHook({
toast: async ({ title, message, variant, duration }) => {
await ctx.client.tui
.showToast({
body: {
title,
message,
variant: variant ?? "warning",
duration: duration ?? 5000,
},
})
.catch(() => {})
},
onApplied,
}),
)
}

View File

@@ -1,13 +1,10 @@
import type { OhMyOpenCodeConfig, HookName } from "../../config" import type { OhMyOpenCodeConfig, HookName } from "../../config"
import type { ModelCacheState } from "../../plugin-state" import type { ModelCacheState } from "../../plugin-state"
import type { PluginContext } from "../types"
import { import {
createContextWindowMonitorHook, createContextWindowMonitorHook,
createSessionRecoveryHook, createSessionRecoveryHook,
createSessionNotification, createSessionNotification,
createThinkModeHook, createThinkModeHook,
createModelFallbackHook,
createAnthropicContextWindowLimitRecoveryHook, createAnthropicContextWindowLimitRecoveryHook,
createAutoUpdateCheckerHook, createAutoUpdateCheckerHook,
createAgentUsageReminderHook, createAgentUsageReminderHook,
@@ -31,10 +28,10 @@ import {
detectExternalNotificationPlugin, detectExternalNotificationPlugin,
getNotificationConflictWarning, getNotificationConflictWarning,
log, log,
normalizeSDKResponse,
} from "../../shared" } from "../../shared"
import { safeCreateHook } from "../../shared/safe-create-hook" import { safeCreateHook } from "../../shared/safe-create-hook"
import { sessionExists } from "../../tools" import { sessionExists } from "../../tools"
import { createConfiguredModelFallbackHook } from "./create-model-fallback-session-hook"
export type SessionHooks = { export type SessionHooks = {
contextWindowMonitor: ReturnType<typeof createContextWindowMonitorHook> | null contextWindowMonitor: ReturnType<typeof createContextWindowMonitorHook> | null
@@ -42,7 +39,7 @@ export type SessionHooks = {
sessionRecovery: ReturnType<typeof createSessionRecoveryHook> | null sessionRecovery: ReturnType<typeof createSessionRecoveryHook> | null
sessionNotification: ReturnType<typeof createSessionNotification> | null sessionNotification: ReturnType<typeof createSessionNotification> | null
thinkMode: ReturnType<typeof createThinkModeHook> | null thinkMode: ReturnType<typeof createThinkModeHook> | null
modelFallback: ReturnType<typeof createModelFallbackHook> | null modelFallback: ReturnType<typeof createConfiguredModelFallbackHook>
anthropicContextWindowLimitRecovery: ReturnType<typeof createAnthropicContextWindowLimitRecoveryHook> | null anthropicContextWindowLimitRecovery: ReturnType<typeof createAnthropicContextWindowLimitRecoveryHook> | null
autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook> | null autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook> | null
agentUsageReminder: ReturnType<typeof createAgentUsageReminderHook> | null agentUsageReminder: ReturnType<typeof createAgentUsageReminderHook> | null
@@ -63,7 +60,7 @@ export type SessionHooks = {
} }
export function createSessionHooks(args: { export function createSessionHooks(args: {
ctx: PluginContext ctx: Parameters<typeof createContextWindowMonitorHook>[0]
pluginConfig: OhMyOpenCodeConfig pluginConfig: OhMyOpenCodeConfig
modelCacheState: ModelCacheState modelCacheState: ModelCacheState
isHookEnabled: (hookName: HookName) => boolean isHookEnabled: (hookName: HookName) => boolean
@@ -105,73 +102,12 @@ export function createSessionHooks(args: {
? safeHook("think-mode", () => createThinkModeHook()) ? safeHook("think-mode", () => createThinkModeHook())
: null : null
const enableFallbackTitle = pluginConfig.experimental?.model_fallback_title ?? false const modelFallback = createConfiguredModelFallbackHook({
const fallbackTitleMaxEntries = 200 ctx,
const fallbackTitleState = new Map<string, { baseTitle?: string; lastKey?: string }>() pluginConfig,
const updateFallbackTitle = async (input: { isHookEnabled,
sessionID: string safeHook,
providerID: string })
modelID: string
variant?: string
}) => {
if (!enableFallbackTitle) return
const key = `${input.providerID}/${input.modelID}${input.variant ? `:${input.variant}` : ""}`
const existing = fallbackTitleState.get(input.sessionID) ?? {}
if (existing.lastKey === key) return
if (!existing.baseTitle) {
const sessionResp = await ctx.client.session.get({ path: { id: input.sessionID } }).catch(() => null)
const sessionInfo = sessionResp
? normalizeSDKResponse(sessionResp, null as { title?: string } | null, { preferResponseOnMissingData: true })
: null
const rawTitle = sessionInfo?.title
if (typeof rawTitle === "string" && rawTitle.length > 0) {
existing.baseTitle = rawTitle.replace(/\s*\[fallback:[^\]]+\]$/i, "").trim()
} else {
existing.baseTitle = "Session"
}
}
const variantLabel = input.variant ? ` ${input.variant}` : ""
const newTitle = `${existing.baseTitle} [fallback: ${input.providerID}/${input.modelID}${variantLabel}]`
await ctx.client.session
.update({
path: { id: input.sessionID },
body: { title: newTitle },
query: { directory: ctx.directory },
})
.catch(() => {})
existing.lastKey = key
fallbackTitleState.set(input.sessionID, existing)
if (fallbackTitleState.size > fallbackTitleMaxEntries) {
const oldestKey = fallbackTitleState.keys().next().value
if (oldestKey) fallbackTitleState.delete(oldestKey)
}
}
// Model fallback hook (configurable via model_fallback config + disabled_hooks)
// This handles automatic model switching when model errors occur
const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false
const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback")
? safeHook("model-fallback", () =>
createModelFallbackHook({
toast: async ({ title, message, variant, duration }) => {
await ctx.client.tui
.showToast({
body: {
title,
message,
variant: variant ?? "warning",
duration: duration ?? 5000,
},
})
.catch(() => {})
},
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
}))
: null
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery") const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
? safeHook("anthropic-context-window-limit-recovery", () => ? safeHook("anthropic-context-window-limit-recovery", () =>

View File

@@ -0,0 +1,63 @@
declare const require: (name: string) => any
const { describe, expect, test } = require("bun:test")
import type { OhMyOpenCodeConfig } from "../../config"
import {
hasConfiguredModelFallbacks,
resolveModelFallbackEnabled,
} from "./model-fallback-config"
describe("model-fallback-config", () => {
test("detects agent fallback_models configuration", () => {
//#given
const pluginConfig: OhMyOpenCodeConfig = {
agents: {
sisyphus: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
}
//#when
const result = hasConfiguredModelFallbacks(pluginConfig)
//#then
expect(result).toBe(true)
})
test("auto-enables model fallback when category fallback_models are configured", () => {
//#given
const pluginConfig: OhMyOpenCodeConfig = {
categories: {
quick: {
fallback_models: ["openai/gpt-5.2"],
},
},
}
//#when
const result = resolveModelFallbackEnabled(pluginConfig)
//#then
expect(result).toBe(true)
})
test("keeps model fallback disabled when explicitly turned off", () => {
//#given
const pluginConfig: OhMyOpenCodeConfig = {
model_fallback: false,
agents: {
sisyphus: {
fallback_models: ["openai/gpt-5.2"],
},
},
}
//#when
const result = resolveModelFallbackEnabled(pluginConfig)
//#then
expect(result).toBe(false)
})
})

View File

@@ -0,0 +1,33 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { log, normalizeFallbackModels } from "../../shared"
type FallbackModelsConfig = {
fallback_models?: string | string[]
}
function hasFallbackModels(config: FallbackModelsConfig | undefined): boolean {
return (normalizeFallbackModels(config?.fallback_models)?.length ?? 0) > 0
}
export function hasConfiguredModelFallbacks(pluginConfig: OhMyOpenCodeConfig): boolean {
const agentConfigs = Object.values<FallbackModelsConfig | undefined>(pluginConfig.agents ?? {})
if (agentConfigs.some(hasFallbackModels)) {
return true
}
const categoryConfigs = Object.values<FallbackModelsConfig | undefined>(pluginConfig.categories ?? {})
return categoryConfigs.some(hasFallbackModels)
}
export function resolveModelFallbackEnabled(pluginConfig: OhMyOpenCodeConfig): boolean {
const hasConfiguredFallbacks = hasConfiguredModelFallbacks(pluginConfig)
if (pluginConfig.model_fallback === false && hasConfiguredFallbacks) {
log(
"model_fallback is disabled while fallback_models are configured; set model_fallback=true to keep provider fallback retries enabled",
)
}
return pluginConfig.model_fallback ?? hasConfiguredFallbacks
}

View File

@@ -40,6 +40,28 @@ describe("model-error-classifier", () => {
expect(result).toBe(true) expect(result).toBe(true)
}) })
test("treats FreeUsageLimitError names as retryable", () => {
//#given
const error = { name: "FreeUsageLimitError" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("treats free tier usage limit messages as retryable", () => {
//#given
const error = { message: "Free tier daily limit reached for this provider" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("selectFallbackProvider prefers first connected provider in preference order", () => { test("selectFallbackProvider prefers first connected provider in preference order", () => {
//#given //#given
readConnectedProvidersCacheMock.mockReturnValue(["anthropic", "nvidia"]) readConnectedProvidersCacheMock.mockReturnValue(["anthropic", "nvidia"])

View File

@@ -6,13 +6,14 @@ import { readConnectedProvidersCache } from "./connected-providers-cache"
* These errors completely halt the action loop and should trigger fallback retry. * These errors completely halt the action loop and should trigger fallback retry.
*/ */
const RETRYABLE_ERROR_NAMES = new Set([ const RETRYABLE_ERROR_NAMES = new Set([
"ProviderModelNotFoundError", "providermodelnotfounderror",
"RateLimitError", "ratelimiterror",
"QuotaExceededError", "quotaexceedederror",
"InsufficientCreditsError", "insufficientcreditserror",
"ModelUnavailableError", "modelunavailableerror",
"ProviderConnectionError", "providerconnectionerror",
"AuthenticationError", "authenticationerror",
"freeusagelimiterror",
]) ])
/** /**
@@ -20,24 +21,28 @@ const RETRYABLE_ERROR_NAMES = new Set([
* These errors are typically user-induced or fixable without switching models. * These errors are typically user-induced or fixable without switching models.
*/ */
const NON_RETRYABLE_ERROR_NAMES = new Set([ const NON_RETRYABLE_ERROR_NAMES = new Set([
"MessageAbortedError", "messageabortederror",
"PermissionDeniedError", "permissiondeniederror",
"ContextLengthError", "contextlengtherror",
"TimeoutError", "timeouterror",
"ValidationError", "validationerror",
"SyntaxError", "syntaxerror",
"UserError", "usererror",
]) ])
/** /**
* Message patterns that indicate a retryable error even without a known error name. * Message patterns that indicate a retryable error even without a known error name.
*/ */
const RETRYABLE_MESSAGE_PATTERNS = [ const RETRYABLE_MESSAGE_PATTERNS: Array<string | RegExp> = [
"rate_limit", "rate_limit",
"rate limit", "rate limit",
"quota", "quota",
"quota will reset after", "quota will reset after",
"usage limit has been reached", "usage limit has been reached",
/free\s+usage/i,
/free\s+tier/i,
/daily\s+limit/i,
/limit\s+reached/i,
"all credentials for model", "all credentials for model",
"cooling down", "cooling down",
"exhausted your capacity", "exhausted your capacity",
@@ -77,6 +82,11 @@ function hasProviderAutoRetrySignal(message: string): boolean {
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern)) return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
} }
function matchesRetryableMessagePattern(message: string): boolean {
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) =>
typeof pattern === "string" ? message.includes(pattern) : pattern.test(message))
}
export interface ErrorInfo { export interface ErrorInfo {
name?: string name?: string
message?: string message?: string
@@ -89,12 +99,14 @@ export interface ErrorInfo {
export function isRetryableModelError(error: ErrorInfo): boolean { export function isRetryableModelError(error: ErrorInfo): boolean {
// If we have an error name, check against known lists // If we have an error name, check against known lists
if (error.name) { if (error.name) {
const normalizedErrorName = error.name.toLowerCase()
// Explicit non-retryable takes precedence // Explicit non-retryable takes precedence
if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) { if (NON_RETRYABLE_ERROR_NAMES.has(normalizedErrorName)) {
return false return false
} }
// Check if it's a known retryable error // Check if it's a known retryable error
if (RETRYABLE_ERROR_NAMES.has(error.name)) { if (RETRYABLE_ERROR_NAMES.has(normalizedErrorName)) {
return true return true
} }
} }
@@ -104,7 +116,7 @@ export function isRetryableModelError(error: ErrorInfo): boolean {
if (hasProviderAutoRetrySignal(msg)) { if (hasProviderAutoRetrySignal(msg)) {
return true return true
} }
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern)) return matchesRetryableMessagePattern(msg)
} }
/** /**