From 1a3c48015520b88bc5754e1074262d7cebd4a1c8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 11:14:05 -0700 Subject: [PATCH] fix: shortcut live session model redirects during fallback (cherry picked from commit 480a3f66c9f76999a5ef68b1a8cb4f2ca68f4f15) --- CHANGELOG.md | 1 + src/agents/model-fallback.test.ts | 49 +++++++++++++++++++++++++++++++ src/agents/model-fallback.ts | 22 ++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d53ca61ac5b..5884e7e752f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip. - Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex. - Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex. - Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet. diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index f9ea9a8e045..663e1ad32cd 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -707,6 +707,55 @@ describe("runWithModelFallback", () => { expect(run).toHaveBeenCalledTimes(2); }); + it("jumps directly to a later live-session model switch candidate (#57471)", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: [ + "anthropic/claude-haiku-3-5", + "anthropic/claude-sonnet-4-6", + "openrouter/deepseek-chat", + ], + }, + }, + }, + }); + const switchError = new LiveSessionModelSwitchError({ + provider: "anthropic", + model: "claude-sonnet-4-6", + }); + const run = vi.fn(async (provider: string, model: string) => { + if (provider === "openai" && model === "gpt-4.1-mini") { + throw switchError; + } + if (provider === "anthropic" && model === "claude-sonnet-4-6") { + return "ok"; + } + throw new Error(`unexpected fallback candidate: ${provider}/${model}`); + }); + const onError = vi.fn(); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + onError, + }); + + expect(result.result).toBe("ok"); + expect(result.provider).toBe("anthropic"); + expect(result.model).toBe("claude-sonnet-4-6"); + expect(result.attempts).toEqual([]); + expect(onError).not.toHaveBeenCalled(); + expect(run.mock.calls).toEqual([ + ["openai", "gpt-4.1-mini"], + ["anthropic", "claude-sonnet-4-6"], + ]); + }); + it("falls back on auth errors", async () => { await expectFallsBackToHaiku({ provider: "openai", diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index a0e489327ac..5bc291b8fc9 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -326,6 +326,18 @@ function recordFailedCandidateAttempt(params: { }); } +function findLaterLiveSessionModelSwitchCandidateIndex(params: { + error: LiveSessionModelSwitchError; + candidates: ModelCandidate[]; + currentIndex: number; +}): number | null { + const targetKey = modelKey(params.error.provider, params.error.model); + const targetIndex = params.candidates.findIndex( + (candidate) => modelKey(candidate.provider, candidate.model) === targetKey, + ); + return targetIndex > params.currentIndex ? targetIndex : null; +} + function throwFallbackFailureSummary(params: { attempts: FallbackAttempt[]; candidates: ModelCandidate[]; @@ -924,6 +936,16 @@ export async function runWithModelFallback(params: { // instead of re-throwing and triggering infinite retry loops in the // outer runner. (#58466) if (err instanceof LiveSessionModelSwitchError) { + const liveSwitchTargetIndex = findLaterLiveSessionModelSwitchCandidateIndex({ + error: err, + candidates, + currentIndex: i, + }); + if (liveSwitchTargetIndex !== null) { + i = liveSwitchTargetIndex - 1; + continue; + } + const switchMsg = err.message; const switchNormalized = new FailoverError(switchMsg, { reason: "overloaded",