Skip to content

Model fallback broken: LiveSessionModelSwitchError overrides fallback candidates #57857

Description

@mttconseil

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.7openrouter/moonshotai/kimi-k2.5 → 4x nexos models → minimax/MiniMax-M2.7-highspeed
  • Agent: main (with agentId set)

Reproduction

  1. Configure a primary model and fallback models in agents.defaults.model
  2. Exhaust the primary model's quota (e.g., ChatGPT Plus plan usage limit)
  3. 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:

  1. 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
  2. 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
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions