fix: clear auto-failover model overrides (#69365) (thanks @Chevron7Locked)

This commit is contained in:
Peter Steinberger
2026-04-21 02:54:59 +01:00
parent dc0e966ed2
commit 215d5fb320
3 changed files with 88 additions and 46 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Gateway/pairing: treat loopback shared-secret node-host, TUI, and gateway clients as local for pairing decisions, so trusted local tools no longer reconnect as remote clients and fail with `pairing required`. (#69431) Thanks @SARAMALI15792.
- Active Memory: degrade gracefully when memory recall fails during prompt building, logging a warning and letting the reply continue without memory context instead of failing the whole turn. (#69485) Thanks @Magicray1217.
- Ollama: add provider-policy defaults for `baseUrl` and `models` so implicit local discovery can run before config validation rejects a minimal Ollama provider config. (#69370) Thanks @PratikRai0101.
- Agents/model selection: clear transient auto-failover session overrides before each turn so recovered primary models are retried immediately without emitting user-override reset warnings. (#69365) Thanks @hitesh-github99.
- Telegram/status reactions: honor `messages.removeAckAfterReply` when lifecycle status reactions are enabled, clearing or restoring the reaction after success/error using the configured hold timings. (#68067) Thanks @poiskgit.
- Web search/plugins: resolve plugin-scoped SecretRef API keys for bundled Exa, Firecrawl, Gemini, Kimi, Perplexity, Tavily, and Grok web-search providers when they are selected through the shared web-search config. (#68424) Thanks @afurm.
- Telegram/polling: raise the default polling watchdog threshold from 90s to 120s and add configurable `channels.telegram.pollingStallThresholdMs` (also per-account) so long-running Telegram work gets more room before polling is treated as stalled. (#57737) Thanks @Vitalcheffe.

View File

@@ -568,7 +568,6 @@ describe("createModelSelectionState auto-failover override self-healing", () =>
modelOverrideSource: "auto",
});
// Provider/model should revert to the configured primary, not the fallback.
// Provider/model should revert to the configured primary, not the fallback.
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
@@ -581,6 +580,45 @@ describe("createModelSelectionState auto-failover override self-healing", () =>
expect(state.resetModelOverride).toBe(false);
});
it("clears a disallowed auto-failover override without reporting an allowlist reset", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: `${defaultProvider}/${defaultModel}` },
models: {
[`${defaultProvider}/${defaultModel}`]: {},
},
},
},
} as OpenClawConfig;
const sessionEntry = makeEntry({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
});
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: "openrouter",
model: "minimax/minimax-m2.7",
hasModelDirective: false,
});
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
expect(state.resetModelOverride).toBe(false);
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined();
});
it("resets in-memory provider/model even when caller pre-loaded the fallback", async () => {
// Simulates get-reply-directives.ts preloading provider/model from stored override
// before calling createModelSelectionState. Our fix must update those in-memory

View File

@@ -304,6 +304,8 @@ export async function createModelSelectionState(params: {
overrideProvider: sessionEntry?.providerOverride,
overrideModel: sessionEntry?.modelOverride,
});
const hadDirectAutoSessionOverride =
sessionEntry?.modelOverrideSource === "auto" && Boolean(directStoredOverride);
if (needsModelCatalog) {
modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg });
@@ -339,7 +341,42 @@ export async function createModelSelectionState(params: {
logStage("configured-catalog-ready", `entries=${configuredModelCatalog.length}`);
}
if (sessionEntry && sessionStore && sessionKey && directStoredOverride) {
// Auto-failover overrides are transient: on this 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.
//
// Clear this before allowlist validation so an old fallback outside the current
// agent allowlist does not emit the unrelated "Model override not allowed" event.
if (hadDirectAutoSessionOverride && sessionEntry && sessionStore && sessionKey) {
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;
});
}
// Reset in-memory selection to the configured primary. The caller-provided
// provider/model may already be set to the fallback by stored-override preload
// in get-reply.ts; updating them here ensures this turn retries the primary.
provider = defaultProvider;
model = defaultModel;
}
}
if (
sessionEntry &&
sessionStore &&
sessionKey &&
directStoredOverride &&
!hadDirectAutoSessionOverride
) {
const normalizedOverride = normalizeModelRef(
directStoredOverride.provider,
directStoredOverride.model,
@@ -364,55 +401,21 @@ export async function createModelSelectionState(params: {
}
}
const storedOverride = resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey,
parentSessionKey,
defaultProvider,
});
const storedOverride = hadDirectAutoSessionOverride
? undefined
: resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey,
parentSessionKey,
defaultProvider,
});
// Skip stored session model override only when an explicit heartbeat.model
// was resolved. Heartbeat runs without heartbeat.model should still inherit
// the regular session/parent model override behavior.
const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true;
// Auto-failover overrides are transient: on this 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.
//
// Note: channel model overrides (channels.modelByChannel) are skipped when
// hasSessionModelOverride was true at get-reply-directives preload time. They
// resume on the following turn once the session state is clear.
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;
});
}
// Reset in-memory selection to the configured primary. The caller-provided
// provider/model were already set to the fallback by the stored-override
// preload in get-reply-directives.ts; updating them here ensures this turn
// retries the primary rather than incurring one extra fallback call.
provider = defaultProvider;
model = defaultModel;
// Do NOT set resetModelOverride — that flag triggers a "Model override not
// allowed for this agent" system event, which is incorrect for auto-heal.
// The override was valid; it just expired after the primary recovered.
}
}
if (storedOverride?.model && !skipStoredOverride && !isAutoSessionOverride) {
if (storedOverride?.model && !skipStoredOverride) {
const normalizedStoredOverride = normalizeModelRef(
storedOverride.provider || defaultProvider,
storedOverride.model,