Fix cooldown fallback switching across model/runtime fallback hooks

This commit is contained in:
Ravi Tharuma
2026-03-04 18:35:09 +01:00
committed by YeonGyu-Kim
parent eb43bd7c43
commit 512bc05404
11 changed files with 803 additions and 38 deletions
+108 -28
View File
@@ -13,11 +13,15 @@ import {
import {
clearPendingModelFallback,
clearSessionFallbackChain,
setSessionFallbackChain,
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { getFallbackModelsForSession } from "../hooks/runtime-fallback/fallback-models";
import { resetMessageCursor } from "../shared";
import { getAgentConfigKey } from "../shared/agent-display-names";
import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier";
import type { FallbackEntry } from "../shared/model-requirements";
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools";
@@ -43,6 +47,28 @@ function normalizeFallbackModelID(modelID: string): string {
.replace(/-high$/i, "");
}
function normalizeRetryStatusMessage(message: string): string {
return message
.replace(/\[retrying in [^\]]*attempt\s*#\d+\]/gi, "[retrying]")
.replace(/retrying in\s+[^(]*attempt\s*#\d+/gi, "retrying")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function extractRetryAttempt(statusAttempt: unknown, message: string): string {
if (typeof statusAttempt === "number" && Number.isFinite(statusAttempt)) {
return String(statusAttempt);
}
const attemptMatch = message.match(/attempt\s*#\s*(\d+)/i);
if (attemptMatch?.[1]) {
return attemptMatch[1];
}
return "?";
}
function extractErrorName(error: unknown): string | undefined {
if (isRecord(error) && typeof error.name === "string") return error.name;
if (error instanceof Error) return error.name;
@@ -97,6 +123,48 @@ function extractProviderModelFromErrorMessage(message: string): { providerID?: s
return {};
}
function parseFallbackModelEntry(
model: string,
defaultProviderID: string,
): FallbackEntry | undefined {
const trimmed = model.trim();
if (!trimmed) return undefined;
const parts = trimmed.split("/");
const providerID = parts.length >= 2 ? parts[0].trim() : defaultProviderID;
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed;
if (!providerID || !rawModelID) return undefined;
const variantMatch = rawModelID.match(/^(.*)\(([^()]+)\)\s*$/);
if (variantMatch) {
const parsedModelID = variantMatch[1]?.trim();
const parsedVariant = variantMatch[2]?.trim();
if (parsedModelID && parsedVariant) {
return { providers: [providerID], model: parsedModelID, variant: parsedVariant };
}
}
return { providers: [providerID], model: rawModelID };
}
function applyUserConfiguredFallbackChain(
sessionID: string,
agentName: string,
currentProviderID: string,
pluginConfig: OhMyOpenCodeConfig,
): void {
const agentKey = getAgentConfigKey(agentName);
const configuredFallbackModels = getFallbackModelsForSession(sessionID, agentKey, pluginConfig);
if (configuredFallbackModels.length === 0) return;
const fallbackChain = configuredFallbackModels
.map((model) => parseFallbackModelEntry(model, currentProviderID))
.filter((entry): entry is FallbackEntry => entry !== undefined);
if (fallbackChain.length > 0) {
setSessionFallbackChain(sessionID, fallbackChain);
}
}
function isCompactionAgent(agent: string): boolean {
return agent.toLowerCase() === "compaction";
@@ -116,6 +184,11 @@ export function createEventHandler(args: {
client: {
session: {
abort: (input: { path: { id: string } }) => Promise<unknown>;
promptAsync?: (input: {
path: { id: string };
body: { parts: Array<{ type: "text"; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
prompt: (input: {
path: { id: string };
body: { parts: Array<{ type: "text"; text: string }> };
@@ -176,6 +249,29 @@ export function createEventHandler(args: {
return !subagentSessions.has(sessionID);
};
const autoContinueAfterFallback = async (sessionID: string, source: string): Promise<void> => {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => {
log("[event] model-fallback abort failed", { sessionID, source, error });
});
const promptBody = {
path: { id: sessionID },
body: { parts: [{ type: "text" as const, text: "continue" }] },
query: { directory: pluginContext.directory },
};
if (typeof pluginContext.client.session.promptAsync === "function") {
await pluginContext.client.session.promptAsync(promptBody).catch((error) => {
log("[event] model-fallback promptAsync failed", { sessionID, source, error });
});
return;
}
await pluginContext.client.session.prompt(promptBody).catch((error) => {
log("[event] model-fallback prompt failed", { sessionID, source, error });
});
};
return async (input): Promise<void> => {
pruneRecentSyntheticIdles({
recentSyntheticIdles,
@@ -310,6 +406,7 @@ export function createEventHandler(args: {
const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
const currentModel = normalizeFallbackModelID(rawModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
@@ -319,15 +416,7 @@ export function createEventHandler(args: {
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
await autoContinueAfterFallback(sessionID, "message.updated");
}
}
}
@@ -342,10 +431,14 @@ 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" && isModelFallbackEnabled) {
if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
try {
const retryMessage = typeof status.message === "string" ? status.message : "";
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
const parsedForKey = extractProviderModelFromErrorMessage(retryMessage);
const retryAttempt = extractRetryAttempt(status.attempt, retryMessage);
// Deduplicate countdown updates for the same retry attempt/model.
// Messages like "retrying in 7m 56s" change every second but should only trigger once.
const retryKey = `${retryAttempt}:${parsedForKey.providerID ?? ""}/${parsedForKey.modelID ?? ""}:${normalizeRetryStatusMessage(retryMessage)}`;
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
return;
}
@@ -370,6 +463,7 @@ export function createEventHandler(args: {
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
@@ -378,14 +472,7 @@ export function createEventHandler(args: {
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
await autoContinueAfterFallback(sessionID, "session.status");
}
}
}
@@ -448,6 +535,7 @@ export function createEventHandler(args: {
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
@@ -456,15 +544,7 @@ export function createEventHandler(args: {
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
await autoContinueAfterFallback(sessionID, "session.error");
}
}
}