mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
fix(model-selection): clear auto-failover overrides so primary is retried on each turn
When runWithModelFallback falls back to a secondary provider it writes providerOverride/modelOverride/modelOverrideSource:"auto" to the session. On subsequent turns createModelSelectionState read this stored override and passed the fallback provider directly to runWithModelFallback, so the configured primary was never retried — the session was permanently pinned to the fallback even after the primary recovered. Fix: at model-selection ingress, when the direct session override has modelOverrideSource "auto" (set by a previous automatic fallback, not a user /model command), clear the override and retry the configured primary. If the primary is still down runWithModelFallback will fall back and re-set the auto override for that turn. Once the primary recovers the override stays clear. User-selected overrides (modelOverrideSource "user" or legacy undefined+model) are preserved unchanged. Covered by four new unit tests in model-selection.test.ts: - auto-failover override cleared and primary retried - user-selected override preserved - legacy override without source field preserved - parent-session auto-override applied to child (not cleared by child logic)
This commit is contained in:
committed by
Peter Steinberger
parent
76d72d48f3
commit
f2abe28d40
@@ -529,6 +529,122 @@ describe("createModelSelectionState respects session model override", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createModelSelectionState auto-failover override self-healing", () => {
|
||||
const defaultProvider = "mac-studio";
|
||||
const defaultModel = "MiniMax-M2.7-MLX";
|
||||
const sessionKey = "agent:main:telegram:direct:1";
|
||||
|
||||
async function resolveStateWithOverride(params: {
|
||||
providerOverride: string;
|
||||
modelOverride: string;
|
||||
modelOverrideSource: "auto" | "user" | undefined;
|
||||
}) {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionEntry = makeEntry({
|
||||
providerOverride: params.providerOverride,
|
||||
modelOverride: params.modelOverride,
|
||||
modelOverrideSource: params.modelOverrideSource,
|
||||
});
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
const state = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: cfg.agents?.defaults,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
hasModelDirective: false,
|
||||
});
|
||||
return { state, sessionEntry, sessionStore };
|
||||
}
|
||||
|
||||
it("clears auto-failover override and retries the configured primary", async () => {
|
||||
const { state, sessionStore } = await resolveStateWithOverride({
|
||||
providerOverride: "openrouter",
|
||||
modelOverride: "minimax/minimax-m2.7",
|
||||
modelOverrideSource: "auto",
|
||||
});
|
||||
|
||||
// Provider/model should revert to the configured primary, not the fallback.
|
||||
expect(state.provider).toBe(defaultProvider);
|
||||
expect(state.model).toBe(defaultModel);
|
||||
// The auto override should be cleared from session state.
|
||||
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
|
||||
expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined();
|
||||
expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined();
|
||||
expect(state.resetModelOverride).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a user-selected override across turns", async () => {
|
||||
const { state, sessionStore } = await resolveStateWithOverride({
|
||||
providerOverride: "openrouter",
|
||||
modelOverride: "minimax/minimax-m2.7",
|
||||
modelOverrideSource: "user",
|
||||
});
|
||||
|
||||
// User-selected override must persist.
|
||||
expect(state.provider).toBe("openrouter");
|
||||
expect(state.model).toBe("minimax/minimax-m2.7");
|
||||
expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter");
|
||||
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
|
||||
expect(state.resetModelOverride).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves a legacy override with no modelOverrideSource (treated as user)", async () => {
|
||||
// Sessions persisted before modelOverrideSource was introduced lack the field.
|
||||
// Backward-compat rule: missing source + present override = user selection.
|
||||
const { state, sessionStore } = await resolveStateWithOverride({
|
||||
providerOverride: "openrouter",
|
||||
modelOverride: "minimax/minimax-m2.7",
|
||||
modelOverrideSource: undefined,
|
||||
});
|
||||
|
||||
expect(state.provider).toBe("openrouter");
|
||||
expect(state.model).toBe("minimax/minimax-m2.7");
|
||||
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
|
||||
expect(state.resetModelOverride).toBe(false);
|
||||
});
|
||||
|
||||
it("does not touch an auto-failover override inherited from a parent session", async () => {
|
||||
// Auto clearing only applies to a direct session override, not one inherited
|
||||
// from a parent. The parent's own session state is managed separately.
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const parentKey = "agent:main:telegram:direct:1";
|
||||
const childKey = "agent:main:telegram:direct:1:thread:99";
|
||||
const parentEntry = makeEntry({
|
||||
providerOverride: "openrouter",
|
||||
modelOverride: "minimax/minimax-m2.7",
|
||||
modelOverrideSource: "auto",
|
||||
});
|
||||
const childEntry = makeEntry(); // no override of its own
|
||||
const sessionStore = { [parentKey]: parentEntry, [childKey]: childEntry };
|
||||
|
||||
const state = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: cfg.agents?.defaults,
|
||||
sessionEntry: childEntry,
|
||||
sessionStore,
|
||||
sessionKey: childKey,
|
||||
parentSessionKey: parentKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
hasModelDirective: false,
|
||||
});
|
||||
|
||||
// Parent auto-override is applied to the child (it has no direct override).
|
||||
expect(state.provider).toBe("openrouter");
|
||||
expect(state.model).toBe("minimax/minimax-m2.7");
|
||||
// Parent session entry is not modified by the child's selection logic.
|
||||
expect(sessionStore[parentKey]?.providerOverride).toBe("openrouter");
|
||||
expect(state.resetModelOverride).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createModelSelectionState resolveDefaultReasoningLevel", () => {
|
||||
it("returns on when catalog model has reasoning true", async () => {
|
||||
const { loadModelCatalog } = await import("../../agents/model-catalog.runtime.js");
|
||||
|
||||
@@ -375,7 +375,32 @@ export async function createModelSelectionState(params: {
|
||||
// was resolved. Heartbeat runs without heartbeat.model should still inherit
|
||||
// the regular session/parent model override behavior.
|
||||
const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true;
|
||||
if (storedOverride?.model && !skipStoredOverride) {
|
||||
|
||||
// Auto-failover overrides are transient: on the next turn, retry the configured
|
||||
// primary so the session self-heals when the primary recovers. The fallback loop
|
||||
// in runWithModelFallback will re-set the override if the primary is still down.
|
||||
// User-selected overrides (/model command) are preserved across turns.
|
||||
const isAutoSessionOverride =
|
||||
storedOverride?.source === "session" && sessionEntry?.modelOverrideSource === "auto";
|
||||
if (isAutoSessionOverride && sessionEntry && sessionStore && sessionKey && !resetModelOverride) {
|
||||
const { updated } = applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
|
||||
});
|
||||
if (updated) {
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await (
|
||||
await loadSessionStoreRuntime()
|
||||
).updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
resetModelOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (storedOverride?.model && !skipStoredOverride && !isAutoSessionOverride) {
|
||||
const normalizedStoredOverride = normalizeModelRef(
|
||||
storedOverride.provider || defaultProvider,
|
||||
storedOverride.model,
|
||||
|
||||
Reference in New Issue
Block a user