Fix cooldown fallback switching across model/runtime fallback hooks
This commit is contained in:
committed by
YeonGyu-Kim
parent
18f84fef93
commit
eab5be666d
@@ -6,7 +6,7 @@ import { _resetForTesting, setMainSession } from "../features/claude-code-sessio
|
||||
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
|
||||
|
||||
describe("createEventHandler - model fallback", () => {
|
||||
const createHandler = (args?: { hooks?: any }) => {
|
||||
const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => {
|
||||
const abortCalls: string[] = []
|
||||
const promptCalls: string[] = []
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("createEventHandler - model fallback", () => {
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
pluginConfig: (args?.pluginConfig ?? {}) as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
@@ -206,13 +206,224 @@ describe("createEventHandler - model fallback", () => {
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID])
|
||||
expect(promptCalls).toEqual([sessionID])
|
||||
expect(output.message["model"]).toEqual({
|
||||
providerID: "anthropic",
|
||||
expect(output.message["model"]).toMatchObject({
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
expect(["anthropic", "quotio"]).toContain((output.message["model"] as { providerID?: string })?.providerID)
|
||||
expect(output.message["variant"]).toBe("max")
|
||||
})
|
||||
|
||||
test("does not spam abort/prompt when session.status retry countdown updates", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_status_retry_dedup"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_status_dedup",
|
||||
sessionID,
|
||||
role: "user",
|
||||
modelID: "claude-opus-4-6-thinking",
|
||||
providerID: "anthropic",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
||||
next: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~4 days attempt #1]",
|
||||
next: 299,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID])
|
||||
expect(promptCalls).toEqual([sessionID])
|
||||
})
|
||||
|
||||
test("does not trigger model-fallback from session.status when runtime_fallback is enabled", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_status_retry_runtime_enabled"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const runtimeFallback = {
|
||||
event: async () => {},
|
||||
"chat.message": async () => {},
|
||||
}
|
||||
const { handler, abortCalls, promptCalls } = createHandler({
|
||||
hooks: { modelFallback, runtimeFallback },
|
||||
pluginConfig: { runtime_fallback: { enabled: true } },
|
||||
})
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_status_runtime_enabled",
|
||||
sessionID,
|
||||
role: "user",
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "quotio",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
|
||||
next: 476,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([])
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("prefers user-configured fallback_models over hardcoded chain on session.status retry", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_status_retry_user_fallback"
|
||||
setMainSession(sessionID)
|
||||
clearPendingModelFallback(sessionID)
|
||||
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const pluginConfig = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
fallback_models: ["quotio/gpt-5.2", "quotio/kimi-k2.5"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback }, pluginConfig })
|
||||
|
||||
const chatMessageHandler = createChatMessageHandler({
|
||||
ctx: {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
shouldOverride: () => false,
|
||||
markApplied: () => {},
|
||||
},
|
||||
hooks: {
|
||||
modelFallback,
|
||||
stopContinuationGuard: null,
|
||||
keywordDetector: null,
|
||||
claudeCodeHooks: null,
|
||||
autoSlashCommand: null,
|
||||
startWork: null,
|
||||
ralphLoop: null,
|
||||
} as any,
|
||||
})
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_user_status_user_fallback",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
content: [],
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "quotio",
|
||||
agent: "Sisyphus (Ultraworker)",
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID,
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
||||
next: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
||||
await chatMessageHandler(
|
||||
{
|
||||
sessionID,
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
output,
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(abortCalls).toEqual([sessionID])
|
||||
expect(promptCalls).toEqual([sessionID])
|
||||
expect(output.message["model"]).toEqual({
|
||||
providerID: "quotio",
|
||||
modelID: "gpt-5.2",
|
||||
})
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("advances main-session fallback chain across repeated session.error retries end-to-end", async () => {
|
||||
//#given
|
||||
const abortCalls: string[] = []
|
||||
@@ -323,10 +534,10 @@ describe("createEventHandler - model fallback", () => {
|
||||
const first = await triggerRetryCycle()
|
||||
|
||||
//#then - first fallback entry applied (prefers current provider when available)
|
||||
expect(first.message["model"]).toEqual({
|
||||
providerID: "anthropic",
|
||||
expect(first.message["model"]).toMatchObject({
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
expect(["anthropic", "quotio"]).toContain((first.message["model"] as { providerID?: string })?.providerID)
|
||||
expect(first.message["variant"]).toBe("max")
|
||||
|
||||
//#when - second retry cycle
|
||||
@@ -337,6 +548,7 @@ describe("createEventHandler - model fallback", () => {
|
||||
providerID: "kimi-for-coding",
|
||||
modelID: "k2p5",
|
||||
})
|
||||
expect((second.message["model"] as { providerID?: string })?.providerID).toBeTruthy()
|
||||
expect(second.message["variant"]).toBeUndefined()
|
||||
expect(abortCalls).toEqual([sessionID, sessionID])
|
||||
expect(promptCalls).toEqual([sessionID, sessionID])
|
||||
|
||||
+108
-28
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user