Summary
When the primary model hits a rate limit or quota error, the fallback system correctly identifies the next candidate model but every fallback candidate is immediately rejected by the LiveSessionModelSwitchError mechanism, creating an infinite retry loop that never actually uses any fallback model.
Environment
- OpenClaw version: 2026.3.28
- Primary model:
openai-codex/gpt-5.4
- Fallback chain:
minimax/MiniMax-M2.7 → openrouter/moonshotai/kimi-k2.5 → 4x nexos models → minimax/MiniMax-M2.7-highspeed
- Agent:
main (with agentId set)
Reproduction
- Configure a primary model and fallback models in
agents.defaults.model
- Exhaust the primary model's quota (e.g., ChatGPT Plus plan usage limit)
- Send a message or wait for heartbeat
Expected: Fallback system switches to the first available fallback model (e.g., minimax)
Actual: Infinite loop — every fallback candidate is rejected by LiveSessionModelSwitchError and forced back to the dead primary
Log signature
[model-fallback/decision] model fallback decision: decision=candidate_failed requested=openai-codex/gpt-5.4 candidate=openai-codex/gpt-5.4 reason=rate_limit next=minimax/MiniMax-M2.7
[agent/embedded] live session model switch detected before attempt for SESSION_ID: minimax/MiniMax-M2.7 -> openai-codex/gpt-5.4
[model-fallback/decision] model fallback decision: decision=candidate_failed requested=openai-codex/gpt-5.4 candidate=minimax/MiniMax-M2.7 reason=unknown next=openrouter/moonshotai/kimi-k2.5
[agent/embedded] live session model switch detected before attempt for SESSION_ID: openrouter/moonshotai/kimi-k2.5 -> openai-codex/gpt-5.4
... (repeats for ALL candidates, then loops back to primary)
Root cause analysis
The conflict is between runWithModelFallback() and resolveLiveSessionModelSelection():
1. Fallback system selects a candidate
runWithModelFallback() (model-fallback.ts) iterates through candidates and calls params.run(candidate.provider, candidate.model) — this correctly passes e.g. minimax/MiniMax-M2.7.
2. Embedded run checks persisted session model
Before each attempt (run loop), the embedded run calls resolvePersistedLiveSelection() which calls resolveLiveSessionModelSelection():
// src/agents/live-model-switch.ts
function resolveLiveSessionModelSelection(params) {
const defaultModelRef = agentId
? resolveDefaultModelForAgent({ cfg, agentId }) // ← always returns config primary!
: { provider: params.defaultProvider, model: params.defaultModel };
const entry = loadSessionStore(...)[sessionKey];
const provider = entry?.providerOverride?.trim() || defaultModelRef.provider;
const model = entry?.modelOverride?.trim() || defaultModelRef.model;
return { provider, model, ... };
}
When agentId is set, resolveDefaultModelForAgent() always returns the config primary (openai-codex/gpt-5.4), regardless of which fallback candidate is currently being attempted.
Since the session entry typically has no providerOverride/modelOverride (cleared when using the default model), the function always returns the primary model.
3. Mismatch triggers LiveSessionModelSwitchError
// The check before each attempt:
const nextSelection = resolvePersistedLiveSelection(); // → openai-codex/gpt-5.4
if (hasDifferentLiveSessionModelSelection(resolveCurrentLiveSelection(), nextSelection)) {
// current = minimax (from fallback), persisted = openai-codex (from config)
throw new LiveSessionModelSwitchError(nextSelection); // → forces back to primary
}
4. Fallback system treats it as unknown failure
runFallbackCandidate() catches LiveSessionModelSwitchError but doesn't recognize it as a FailoverError, so it's logged as reason=unknown and the next candidate is tried — which hits the exact same problem.
Suggested fix
The "live session model switch" check should be aware of the fallback context. Possible approaches:
- Skip the live session model switch check during fallback runs — pass a flag like
isFallbackAttempt to the embedded run and skip the resolvePersistedLiveSelection() comparison when true
- Use the current candidate as the default in
resolveLiveSessionModelSelection() — when agentId is set AND no providerOverride/modelOverride exists in the session, fall back to the passed defaultProvider/defaultModel instead of the config primary
- Recognize
LiveSessionModelSwitchError in runFallbackCandidate() and rethrow it so the fallback system doesn't try to iterate through candidates that will all fail the same way
Current workaround
Change the agents.defaults.model.primary to a working model (e.g., minimax) and put the rate-limited model as the first fallback. This is the only way to make the agent functional when the primary is down.
Summary
When the primary model hits a rate limit or quota error, the fallback system correctly identifies the next candidate model but every fallback candidate is immediately rejected by the
LiveSessionModelSwitchErrormechanism, creating an infinite retry loop that never actually uses any fallback model.Environment
openai-codex/gpt-5.4minimax/MiniMax-M2.7→openrouter/moonshotai/kimi-k2.5→ 4x nexos models →minimax/MiniMax-M2.7-highspeedmain(withagentIdset)Reproduction
agents.defaults.modelExpected: Fallback system switches to the first available fallback model (e.g., minimax)
Actual: Infinite loop — every fallback candidate is rejected by
LiveSessionModelSwitchErrorand forced back to the dead primaryLog signature
Root cause analysis
The conflict is between
runWithModelFallback()andresolveLiveSessionModelSelection():1. Fallback system selects a candidate
runWithModelFallback()(model-fallback.ts) iterates through candidates and callsparams.run(candidate.provider, candidate.model)— this correctly passes e.g.minimax/MiniMax-M2.7.2. Embedded run checks persisted session model
Before each attempt (run loop), the embedded run calls
resolvePersistedLiveSelection()which callsresolveLiveSessionModelSelection():When
agentIdis set,resolveDefaultModelForAgent()always returns the config primary (openai-codex/gpt-5.4), regardless of which fallback candidate is currently being attempted.Since the session entry typically has no
providerOverride/modelOverride(cleared when using the default model), the function always returns the primary model.3. Mismatch triggers LiveSessionModelSwitchError
4. Fallback system treats it as unknown failure
runFallbackCandidate()catchesLiveSessionModelSwitchErrorbut doesn't recognize it as aFailoverError, so it's logged asreason=unknownand the next candidate is tried — which hits the exact same problem.Suggested fix
The "live session model switch" check should be aware of the fallback context. Possible approaches:
isFallbackAttemptto the embedded run and skip theresolvePersistedLiveSelection()comparison when trueresolveLiveSessionModelSelection()— whenagentIdis set AND noproviderOverride/modelOverrideexists in the session, fall back to the passeddefaultProvider/defaultModelinstead of the config primaryLiveSessionModelSwitchErrorinrunFallbackCandidate()and rethrow it so the fallback system doesn't try to iterate through candidates that will all fail the same wayCurrent workaround
Change the
agents.defaults.model.primaryto a working model (e.g., minimax) and put the rate-limited model as the first fallback. This is the only way to make the agent functional when the primary is down.