AEP-021: LLM Provider Fallback Chain¶
| Field | Value |
|---|---|
| Status | proposed |
| Priority | P1 |
| Effort | Low-Medium (2-3 days) |
| Impact | High |
| Dependencies | none (reuses existing resilience primitives) |
Pattern borrowed from the Hermes agent architecture (
runtime_provider.pyfallback chains). Adapted to Orrery'sresolve_model()factory and existingCircuitBreaker/@with_retryprimitives.
Gap Analysis¶
Current Implementation¶
resolve_model() (core/orrery_core/agent/base.py) reads MODEL_PROVIDER + MODEL_NAME and returns one model — a Gemini string, or a single LiteLlm(model=...) for Claude/OpenAI/Ollama. Every agent gets its model through this one factory via create_agent().
Orrery's resilience layer is real but scoped to tools:
ResiliencePlugin+CircuitBreakerwrap per-tool calls.@with_retryadds exponential backoff to async tool functions.
Nothing protects the model call itself. If the configured provider is down, rate-limited (sustained 429), or returns 5xx, the agent turn fails and there is no second option — a provider incident is a full platform outage.
Why this matters now¶
The platform is multi-provider capable (LiteLLM) but not multi-provider resilient: it can be configured for Gemini or Claude, but a running deployment is pinned to whichever one MODEL_NAME names. Given AEP-011's HPA can scale to several replicas all pointed at the same provider, a provider-side quota/outage takes down every replica at once. Price and availability also vary ~50x across models, so a fallback chain doubles as a cost-degradation lever (primary Pro → cheaper Flash under pressure).
What's available¶
- LiteLLM already normalizes providers, so a chain is just an ordered list of
resolve_model()-style targets. CircuitBreakerand@with_retryalready exist — the fallback just needs to classify errors (quota / 5xx / timeout = failover; 4xx validation = don't) and advance to the next target.- ADK lets a
BaseLlmbe swapped per request; a thin wrapperBaseLlmcan front the chain transparently to every agent.
Proposed Solution¶
A FallbackLlm that wraps an ordered list of BaseLlm targets and advances on retryable provider errors, reusing the existing circuit breaker per target.
Step 1: Parse a chain from env¶
Extend resolve_model() to read an optional chain:
# MODEL_FALLBACK_CHAIN="anthropic/claude-sonnet-5,gemini-2.0-flash"
def resolve_model_chain() -> str | BaseLlm:
primary = resolve_model() # unchanged default
raw = os.getenv("MODEL_FALLBACK_CHAIN", "").strip()
if not raw:
return primary
fallbacks = [_resolve_one(spec.strip()) for spec in raw.split(",") if spec.strip()]
return FallbackLlm([primary, *fallbacks])
create_agent() calls resolve_model_chain() instead of resolve_model() — a one-line change; every agent inherits failover with zero per-agent wiring.
Step 2: The fallback wrapper¶
class FallbackLlm(BaseLlm):
"""Tries each backend in order; advances on retryable provider errors."""
def __init__(self, targets: list[BaseLlm | str]):
self._targets = [_as_llm(t) for t in targets]
self._breakers = {i: CircuitBreaker(name=f"llm-{i}") for i in range(len(self._targets))}
async def generate_content_async(self, llm_request, **kw):
last_exc = None
for i, target in enumerate(self._targets):
if self._breakers[i].is_open():
continue # skip a known-bad provider
try:
async for resp in target.generate_content_async(llm_request, **kw):
yield resp
self._breakers[i].record_success()
return
except Exception as exc:
if not _is_retryable(exc): # 4xx/validation: caller's fault, don't failover
raise
self._breakers[i].record_failure()
logger.warning(
"llm_failover", extra={"from_target": i, "error": type(exc).__name__}
)
last_exc = exc
raise LlmChainExhausted("all providers failed") from last_exc
Step 3: Classify errors conservatively¶
def _is_retryable(exc: Exception) -> bool:
# Quota / rate-limit / server / timeout → failover.
# Auth / bad-request / content-filter → do NOT failover (would just fail again).
code = getattr(exc, "status_code", None)
if code in (408, 429, 500, 502, 503, 504):
return True
return isinstance(exc, (TimeoutError, ConnectionError))
Step 4: Observability¶
- Emit
llm_failover_total{from_provider,to_provider}and surface open LLM breakers alongside the existing tool-breaker state inMetricsPlugin. - Log each failover at WARN with the request id (already a ContextVar) so a provider incident is greppable.
Caveats to document¶
- Behavioral drift: a fallback model may format tool calls or prose differently. Keep chains within a capability tier (don't fail Pro → a tiny model) and note that evals (AEP-002) run against the primary only.
- Cost inversion: if the fallback is pricier, a primary outage silently raises spend — pair with AEP-015 budget alerts.
- Confirmation state: failover happens inside one turn, so the requester-verified confirmation flow (AEP-013) is unaffected — the pending record is keyed by requester, not model.
Affected Files¶
| File | Change |
|---|---|
core/orrery_core/agent/base.py | Add resolve_model_chain(); create_agent() uses it |
core/orrery_core/agent/fallback.py | New — FallbackLlm, _is_retryable, LlmChainExhausted |
core/orrery_core/metrics.py | Add llm_failover_total; expose LLM breaker state |
core/tests/test_fallback_llm.py | New — failover on 429/5xx, no-failover on 4xx, chain-exhausted, breaker skip |
docs/config/general.md | Document MODEL_FALLBACK_CHAIN |
Acceptance Criteria¶
- [ ]
MODEL_FALLBACK_CHAIN(comma-separatedprovider/modelspecs) parsed into aFallbackLlm - [ ] Empty/unset chain → single-model behavior identical to today (no regression)
- [ ] Failover on
408/429/5xx/timeout; no failover on auth/4xx/content-filter - [ ] Per-target circuit breaker skips a provider that's already tripped
- [ ]
LlmChainExhaustedraised (and audited) only when every target fails - [ ]
llm_failover_totalmetric + WARN log per failover - [ ] Unit tests cover each error class and the exhausted case
Notes¶
- This composes with, not replaces,
@with_retry: retry handles a transient blip on one provider; the chain handles a sustained failure of that provider. Keep per-target retries low (1-2) so the chain advances quickly. - Start with a two-link chain (primary + one fallback). Longer chains add tail-latency risk on a full outage — cap total attempts with a deadline.