From d947743932d65a359fd3540c4e0b540763f7a7e5 Mon Sep 17 00:00:00 2001 From: "youming.tang" Date: Wed, 4 Feb 2026 15:25:25 +0900 Subject: [PATCH] fix(runtime-fallback): per-model cooldown and stricter retry patterns --- src/hooks/runtime-fallback/constants.ts | 6 +- src/hooks/runtime-fallback/index.test.ts | 77 +++++++++++++++--------- src/hooks/runtime-fallback/index.ts | 36 ++++++----- src/hooks/runtime-fallback/types.ts | 7 ++- 4 files changed, 75 insertions(+), 51 deletions(-) diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index a321a57e2..87d03be53 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -29,9 +29,9 @@ export const RETRYABLE_ERROR_PATTERNS = [ /overloaded/i, /temporarily.?unavailable/i, /try.?again/i, - /429/, - /503/, - /529/, + /\b429\b/, + /\b503\b/, + /\b529\b/, ] /** diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 559255bf8..d73fa144d 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" import { createRuntimeFallbackHook, type RuntimeFallbackHook } from "./index" -import type { RuntimeFallbackConfig } from "../../config" +import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config" import * as sharedModule from "../../shared" +import { SessionCategoryRegistry } from "../../shared/session-category-registry" describe("runtime-fallback", () => { let logCalls: Array<{ msg: string; data?: unknown }> @@ -11,12 +12,14 @@ describe("runtime-fallback", () => { beforeEach(() => { logCalls = [] toastCalls = [] + SessionCategoryRegistry.clear() logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => { logCalls.push({ msg, data }) }) }) afterEach(() => { + SessionCategoryRegistry.clear() logSpy?.mockRestore() }) @@ -48,6 +51,16 @@ describe("runtime-fallback", () => { } } + function createMockPluginConfigWithCategoryFallback(fallbackModels: string[]): OhMyOpenCodeConfig { + return { + categories: { + test: { + fallback_models: fallbackModels, + }, + }, + } + } + describe("session.error handling", () => { test("should detect retryable error with status code 429", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() }) @@ -448,11 +461,15 @@ describe("runtime-fallback", () => { }) describe("model switching via chat.message", () => { - test("should set pending fallback model after error", async () => { - const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() }) + test("should apply fallback model on next chat.message after error", async () => { + const hook = createRuntimeFallbackHook(createMockPluginInput(), { + config: createMockConfig({ notify_on_fallback: false }), + pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2", "google/gemini-3-pro"]), + }) const sessionID = "test-session-switch" + SessionCategoryRegistry.register(sessionID, "test") - //#given - session with fallback models configured + //#given await hook.event({ event: { type: "session.created", @@ -460,25 +477,30 @@ describe("runtime-fallback", () => { }, }) - //#when - retryable error occurs + //#when await hook.event({ event: { type: "session.error", - properties: { - sessionID, - error: { statusCode: 429, message: "Rate limit" }, - }, + properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } }, }, }) - //#then - fallback preparation should be logged - const fallbackPrepLog = logCalls.find((c) => c.msg.includes("Preparing fallback") || c.msg.includes("fallback")) - expect(fallbackPrepLog !== undefined || logCalls.some(c => c.msg.includes("No fallback"))).toBe(true) + const output = { message: {}, parts: [] } + await hook["chat.message"]?.( + { sessionID, model: { providerID: "anthropic", modelID: "claude-opus-4-5" } }, + output + ) + + expect(output.message.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) }) test("should notify when fallback occurs", async () => { - const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() }) + const hook = createRuntimeFallbackHook(createMockPluginInput(), { + config: createMockConfig({ notify_on_fallback: true }), + pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]), + }) const sessionID = "test-session-notify" + SessionCategoryRegistry.register(sessionID, "test") await hook.event({ event: { @@ -490,13 +512,12 @@ describe("runtime-fallback", () => { await hook.event({ event: { type: "session.error", - properties: { sessionID, error: { statusCode: 429 }, agent: "sisyphus" }, + properties: { sessionID, error: { statusCode: 429 } }, }, }) - //#then - should show notification toast or prepare fallback - const notifyLog = logCalls.find((c) => c.msg.includes("Preparing fallback") || c.msg.includes("No fallback models")) - expect(notifyLog).toBeDefined() + expect(toastCalls.length).toBe(1) + expect(toastCalls[0]?.message.includes("gpt-5.2")).toBe(true) }) }) @@ -553,9 +574,14 @@ describe("runtime-fallback", () => { describe("cooldown mechanism", () => { test("should respect cooldown period before retrying failed model", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { - config: createMockConfig({ cooldown_seconds: 1 }), + config: createMockConfig({ cooldown_seconds: 60, notify_on_fallback: false }), + pluginConfig: createMockPluginConfigWithCategoryFallback([ + "openai/gpt-5.2", + "anthropic/claude-opus-4-5", + ]), }) const sessionID = "test-session-cooldown" + SessionCategoryRegistry.register(sessionID, "test") await hook.event({ event: { @@ -564,7 +590,7 @@ describe("runtime-fallback", () => { }, }) - //#when - first error occurs + //#when - first error occurs, switches to openai await hook.event({ event: { type: "session.error", @@ -572,11 +598,7 @@ describe("runtime-fallback", () => { }, }) - const firstFallback = logCalls.find((c) => c.msg.includes("Preparing fallback") || c.msg.includes("No fallback models")) - expect(firstFallback).toBeDefined() - - //#when - second error occurs immediately (within cooldown) - logCalls = [] + //#when - second error occurs immediately; tries to switch back to original model but should be in cooldown await hook.event({ event: { type: "session.error", @@ -584,11 +606,8 @@ describe("runtime-fallback", () => { }, }) - //#then - should skip due to cooldown (no new logs or cooldown message) - const hasCooldownSkip = logCalls.some((c) => - c.msg.includes("cooldown") || c.msg.includes("Skipping") - ) - expect(hasCooldownSkip || logCalls.length <= 2).toBe(true) + const cooldownSkipLog = logCalls.find((c) => c.msg.includes("Skipping fallback model in cooldown")) + expect(cooldownSkipLog).toBeDefined() }) }) diff --git a/src/hooks/runtime-fallback/index.ts b/src/hooks/runtime-fallback/index.ts index 06f7f9ec3..3bbc9af10 100644 --- a/src/hooks/runtime-fallback/index.ts +++ b/src/hooks/runtime-fallback/index.ts @@ -1,6 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config" -import type { FallbackState, FallbackResult, RuntimeFallbackHook } from "./types" +import type { FallbackState, FallbackResult, RuntimeFallbackHook, RuntimeFallbackOptions } from "./types" import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS, HOOK_NAME } from "./constants" import { log } from "../../shared/logger" import { SessionCategoryRegistry } from "../../shared/session-category-registry" @@ -10,8 +10,7 @@ function createFallbackState(originalModel: string): FallbackState { originalModel, currentModel: originalModel, fallbackIndex: -1, - lastFallbackTime: 0, - failedModels: new Set(), + failedModels: new Map(), attemptCount: 0, pendingFallbackModel: undefined, } @@ -132,12 +131,10 @@ function getFallbackModelsForSession( } function isModelInCooldown(model: string, state: FallbackState, cooldownSeconds: number): boolean { - if (!state.failedModels.has(model)) return false - + const failedAt = state.failedModels.get(model) + if (failedAt === undefined) return false const cooldownMs = cooldownSeconds * 1000 - const timeSinceLastFallback = Date.now() - state.lastFallbackTime - - return timeSinceLastFallback < cooldownMs + return Date.now() - failedAt < cooldownMs } function findNextAvailableFallback( @@ -180,9 +177,11 @@ function prepareFallback( attempt: state.attemptCount + 1, }) + const failedModel = state.currentModel + const now = Date.now() + state.fallbackIndex = fallbackModels.indexOf(nextModel) - state.failedModels.add(state.currentModel) - state.lastFallbackTime = Date.now() + state.failedModels.set(failedModel, now) state.attemptCount++ state.currentModel = nextModel state.pendingFallbackModel = nextModel @@ -194,7 +193,7 @@ export type { RuntimeFallbackHook, RuntimeFallbackOptions } from "./types" export function createRuntimeFallbackHook( ctx: PluginInput, - options?: { config?: RuntimeFallbackConfig } + options?: RuntimeFallbackOptions ): RuntimeFallbackHook { const config: Required = { enabled: options?.config?.enabled ?? DEFAULT_CONFIG.enabled, @@ -207,11 +206,15 @@ export function createRuntimeFallbackHook( const sessionStates = new Map() let pluginConfig: OhMyOpenCodeConfig | undefined - try { - const { loadPluginConfig } = require("../../plugin-config") - pluginConfig = loadPluginConfig(ctx.directory, ctx) - } catch { - log(`[${HOOK_NAME}] Plugin config not available`) + if (options?.pluginConfig) { + pluginConfig = options.pluginConfig + } else { + try { + const { loadPluginConfig } = require("../../plugin-config") + pluginConfig = loadPluginConfig(ctx.directory, ctx) + } catch { + log(`[${HOOK_NAME}] Plugin config not available`) + } } const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { @@ -238,6 +241,7 @@ export function createRuntimeFallbackHook( if (sessionID) { log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID }) sessionStates.delete(sessionID) + SessionCategoryRegistry.remove(sessionID) } return } diff --git a/src/hooks/runtime-fallback/types.ts b/src/hooks/runtime-fallback/types.ts index 421833015..3ff6334a1 100644 --- a/src/hooks/runtime-fallback/types.ts +++ b/src/hooks/runtime-fallback/types.ts @@ -4,7 +4,7 @@ * Types for managing runtime model fallback when API errors occur. */ -import type { RuntimeFallbackConfig } from "../../config" +import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config" /** * Tracks the state of fallback attempts for a session @@ -13,8 +13,7 @@ export interface FallbackState { originalModel: string currentModel: string fallbackIndex: number - lastFallbackTime: number - failedModels: Set + failedModels: Map attemptCount: number pendingFallbackModel?: string } @@ -57,6 +56,8 @@ export interface FallbackResult { export interface RuntimeFallbackOptions { /** Runtime fallback configuration */ config?: RuntimeFallbackConfig + /** Optional plugin config override (primarily for testing) */ + pluginConfig?: OhMyOpenCodeConfig } export interface RuntimeFallbackHook {