feat(cmd): add /model-fallback command (#196)

* feat(cmd): add /model-fallback command

* fix: apply feedback

* fix: lock usage with _fallback_chain

* fix: apply feedback

* fix: apply feedback

* feat: add tests

* fix: tests

* Update EvoScientist/middleware/model_fallback.py

Co-authored-by: dinos <dinospk1999@gmail.com>

---------

Co-authored-by: dinos <dinospk1999@gmail.com>
This commit is contained in:
Wiktor Cupiał
2026-05-07 15:35:26 +02:00
committed by GitHub
parent 22a65b640d
commit 9e51ec6fdd
8 changed files with 1042 additions and 3 deletions
+10
View File
@@ -432,17 +432,22 @@ def _get_default_middleware():
"""Build the default middleware list."""
from .middleware import (
ContextOverflowMapperMiddleware,
ModelFallbackMiddleware,
ToolErrorHandlerMiddleware,
create_context_editing_middleware,
create_memory_middleware,
create_tool_selector_middleware,
load_fallback_chain,
)
cfg = _ensure_config()
if cfg.model_fallbacks:
load_fallback_chain(cfg.model_fallbacks)
model = _ensure_chat_model()
memory_dir = str(_paths_mod.MEMORIES_DIR)
mw = [
create_context_editing_middleware(model),
ModelFallbackMiddleware(),
ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(),
*create_tool_selector_middleware(model=model),
@@ -543,13 +548,17 @@ def create_cli_agent(
from .backends import CustomSandboxBackend, MergedSkillsBackend
from .middleware import (
ContextOverflowMapperMiddleware,
ModelFallbackMiddleware,
ToolErrorHandlerMiddleware,
create_context_editing_middleware,
create_memory_middleware,
create_tool_selector_middleware,
load_fallback_chain,
)
cfg = _ensure_config(config)
if cfg.model_fallbacks:
load_fallback_chain(cfg.model_fallbacks)
if checkpointer is None:
from langgraph.checkpoint.memory import InMemorySaver
@@ -600,6 +609,7 @@ def create_cli_agent(
model = _ensure_chat_model()
mw: list[AgentMiddleware] = [
create_context_editing_middleware(model),
ModelFallbackMiddleware(),
ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(),
*create_tool_selector_middleware(model=model),
+10 -1
View File
@@ -681,6 +681,12 @@ def run_textual_interactive(
yield Static("", id="status")
def on_mount(self) -> None:
# Register fallback middleware UI callback so messages appear
# as SystemMessage widgets in the chat container.
from ..middleware.model_fallback import set_ui_emit
set_ui_emit(lambda text, style: self._append_system(text, style))
self._render_welcome()
self._render_status()
self.set_interval(1.0, self._render_status)
@@ -2533,7 +2539,10 @@ def run_textual_interactive(
self._do_exit()
def _do_exit(self) -> None:
"""Clean up channels and exit."""
"""Clean up channels, unregister callbacks, and exit."""
from ..middleware.model_fallback import set_ui_emit
set_ui_emit(None)
if self._channel_timer is not None:
self._channel_timer.stop()
self._channel_timer = None
@@ -1,5 +1,5 @@
from __future__ import annotations
from . import channel, general, mcp, model, session, skills
from . import channel, general, mcp, model, model_fallback, session, skills
__all__ = ["channel", "general", "mcp", "model", "session", "skills"]
__all__ = ["channel", "general", "mcp", "model", "model_fallback", "session", "skills"]
@@ -0,0 +1,310 @@
"""Slash command for managing the model fallback chain.
Provides ``/model-fallback`` (alias ``/fallback``) with subcommands to
add, remove, list, clear, save, and display help for fallback models.
"""
from __future__ import annotations
from typing import ClassVar
from ..base import Argument, Command, CommandContext
from ..manager import manager
class ModelFallbackCommand(Command):
"""Manage the model fallback chain.
Subcommands:
add -- Append a model (interactive picker when no args).
remove -- Remove by position (interactive picker in TUI).
list -- Display the current chain.
clear -- Remove all entries.
save -- Persist the chain to the config file.
help -- Show subcommand reference.
"""
name = "/model-fallback"
alias: ClassVar[list[str]] = ["/fallback"]
description = "Manage fallback models (add/remove/list/clear)"
arguments: ClassVar[list[Argument]] = [
Argument(
name="action",
type=str,
description="add|remove|list|clear|save|help",
required=False,
),
]
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
from ...llm.models import MODELS
from ...middleware.model_fallback import (
add_fallback,
clear_fallbacks,
get_fallback_chain,
remove_fallback_at,
serialize_fallback_chain,
)
save = "--save" in args
args = [a for a in args if a != "--save"]
if not args:
await self._show_list(ctx, get_fallback_chain())
return
action = args[0].lower()
if action == "list":
await self._show_list(ctx, get_fallback_chain())
elif action == "add":
if len(args) >= 2:
model_name = args[1]
provider = args[2] if len(args) > 2 else None
if provider is None:
if model_name in MODELS:
_, provider = MODELS[model_name]
else:
ctx.ui.append_system(
f"Unknown model '{model_name}'. Specify provider explicitly: "
f"/model-fallback add {model_name} <provider>",
style="red",
)
return
else:
picked = await self._pick_model(ctx)
if picked is None:
return
model_name, provider = picked
if add_fallback(model_name, provider):
ctx.ui.append_system(
f"Added {model_name} ({provider}) to fallback chain", style="green"
)
else:
ctx.ui.append_system(
f"{model_name} ({provider}) is already in the fallback chain",
style="yellow",
)
return
if save:
self._save_to_config(serialize_fallback_chain())
elif action == "remove":
chain = get_fallback_chain()
if not chain:
ctx.ui.append_system("Fallback chain is empty", style="yellow")
return
if len(args) >= 2:
arg = args[1]
try:
idx = int(arg) - 1
except ValueError:
ctx.ui.append_system(
f"Expected a position number (1-{len(chain)}), got '{arg}'. "
"Use /model-fallback list to see positions.",
style="red",
)
return
removed = remove_fallback_at(idx)
if removed is None:
ctx.ui.append_system(
f"Invalid position {arg}. "
f"Use a number between 1 and {len(chain)}.",
style="red",
)
return
model_name, provider = removed
else:
picked = await self._pick_fallback_to_remove(ctx, chain)
if picked is None:
return
model_name, provider = picked
live_chain = get_fallback_chain()
try:
idx = live_chain.index((model_name, provider))
except ValueError:
ctx.ui.append_system(
f"{model_name} ({provider}) is no longer in the fallback chain",
style="yellow",
)
return
remove_fallback_at(idx)
ctx.ui.append_system(
f"Removed {model_name} ({provider}) from fallback chain",
style="green",
)
if save:
self._save_to_config(serialize_fallback_chain())
elif action == "clear":
clear_fallbacks()
ctx.ui.append_system("Cleared all fallback models", style="green")
if save:
self._save_to_config("")
elif action == "save":
self._save_to_config(serialize_fallback_chain())
ctx.ui.append_system("Fallback chain saved to config", style="green")
elif action == "help":
self._show_help(ctx)
else:
self._show_help(ctx)
async def _pick_model(self, ctx: CommandContext) -> tuple[str, str] | None:
"""Open the interactive model picker to select a fallback model.
Falls back to a usage hint when the UI does not support interactive
widgets (CLI mode without a model argument).
Args:
ctx: Current command context.
Returns:
``(model_name, provider)`` tuple, or ``None`` if cancelled.
"""
if not ctx.ui.supports_interactive:
ctx.ui.append_system(
"Usage: /model-fallback add <model> [provider]", style="yellow"
)
return None
from ...EvoScientist import _ensure_config
from ...llm.models import list_models_by_provider
cfg = _ensure_config()
entries = list_models_by_provider()
ollama_base_url = getattr(cfg, "ollama_base_url", None)
if ollama_base_url:
from ...llm.ollama_discovery import discover_ollama_models
detected = await discover_ollama_models(ollama_base_url, timeout=1.5)
for detected_name in detected:
entries.append((detected_name, detected_name, "ollama"))
entries.append(("Custom Ollama model...", "__custom_ollama__", "ollama"))
result = await ctx.ui.wait_for_model_pick(
entries,
current_model=cfg.model,
current_provider=cfg.provider,
)
if result is None:
return None
name, provider = result
if provider == "ollama" and name in (
"Custom Ollama model...",
"__custom_ollama__",
):
return None
return name, provider
async def _pick_fallback_to_remove(
self, ctx: CommandContext, chain: list[tuple[str, str]]
) -> tuple[str, str] | None:
"""Open the model picker populated with the current fallback chain.
Falls back to a usage hint in CLI mode.
Args:
ctx: Current command context.
chain: The current fallback chain to choose from.
Returns:
``(model_name, provider)`` tuple, or ``None`` if cancelled.
"""
if not ctx.ui.supports_interactive:
ctx.ui.append_system(
"Usage: /model-fallback remove <position> "
"(use /model-fallback list to see positions)",
style="yellow",
)
return None
entries = [(m, m, p) for m, p in chain]
result = await ctx.ui.wait_for_model_pick(
entries, current_model=None, current_provider=None
)
if result is None:
return None
return result
def _show_help(self, ctx: CommandContext) -> None:
"""Render the subcommand reference table.
Args:
ctx: Current command context.
"""
from rich.text import Text
text = Text("/model-fallback subcommands:\n", style="bold")
for cmd, desc in (
(
"add [model] [provider]",
"Add a fallback model (opens picker if omitted)",
),
(
"remove [position]",
"Remove a fallback by position (opens picker in TUI)",
),
("list", "Show the current fallback chain"),
("clear", "Remove all fallback models"),
("save", "Save current fallback chain to config file"),
("help", "Show this help message"),
):
text.append(f" {cmd:<26}", style="cyan")
text.append(f"{desc}\n", style="dim")
text.append(
"\nAdd --save to add/remove/clear to persist the change immediately.\n",
style="dim",
)
ctx.ui.mount_renderable(text)
async def _show_list(
self, ctx: CommandContext, chain: list[tuple[str, str]]
) -> None:
"""Display the current fallback chain as a numbered list.
Args:
ctx: Current command context.
chain: The fallback chain to display.
"""
if not chain:
ctx.ui.append_system("No fallback models configured", style="dim")
ctx.ui.append_system(
"Use /model-fallback add <model> [provider] to add one",
style="dim",
)
return
from rich.text import Text
text = Text("Fallback chain:\n", style="bold")
for idx, (model, provider) in enumerate(chain, 1):
text.append(f" {idx}. ", style="dim")
text.append(model, style="cyan")
text.append(f" ({provider})\n", style="dim")
ctx.ui.mount_renderable(text)
def _save_to_config(self, value: str) -> None:
"""Persist the fallback chain string to the config file.
Args:
value: Serialized chain (``"model:provider,..."``).
"""
from ...config.settings import set_config_value
set_config_value("model_fallbacks", value)
manager.register(ModelFallbackCommand())
+2
View File
@@ -86,6 +86,7 @@ class EvoScientistConfig:
# LLM Settings
provider: str = "anthropic"
model: str = "claude-sonnet-4-5"
model_fallbacks: str = "" # "model:provider,model:provider" fallback chain
# Async Sub-agent Settings
# When True (default), the EvoSci CLI auto-starts a langgraph dev subprocess
@@ -436,6 +437,7 @@ _ENV_MAPPINGS = {
"default_workdir": "EVOSCIENTIST_WORKSPACE_DIR",
"ui_backend": "EVOSCIENTIST_UI_BACKEND",
"log_level": "EVOSCIENTIST_LOG_LEVEL",
"model_fallbacks": "EVOSCIENTIST_MODEL_FALLBACKS",
"reasoning_effort": "EVOSCIENTIST_REASONING_EFFORT",
"channel_debug_tracing": "EVOSCIENTIST_CHANNEL_DEBUG_TRACING",
"ccproxy_port": "EVOSCIENTIST_CCPROXY_PORT",
+3
View File
@@ -22,6 +22,7 @@ from .memory import (
ExtractedMemory,
create_memory_middleware,
)
from .model_fallback import ModelFallbackMiddleware, load_fallback_chain
from .tool_error_handler import ToolErrorHandlerMiddleware
from .tool_selector import create_tool_selector_middleware
from .utils import disable_thinking
@@ -35,6 +36,7 @@ __all__ = [
"EvoMemoryMiddleware",
"EvoMemoryState",
"ExtractedMemory",
"ModelFallbackMiddleware",
"Question",
"ToolErrorHandlerMiddleware",
"compute_context_editing_trigger",
@@ -42,4 +44,5 @@ __all__ = [
"create_memory_middleware",
"create_tool_selector_middleware",
"disable_thinking",
"load_fallback_chain",
]
+378
View File
@@ -0,0 +1,378 @@
"""Middleware that implements model fallback on LLM call failures.
Uses LangChain's AgentMiddleware to intercept model calls. When the primary
model raises an exception, the middleware walks the configured fallback chain,
trying each alternative model in order. Every fallback attempt and its
outcome is surfaced to the user via the registered UI callback.
Errors that indicate a client-side bug (malformed request / HTTP 400) or a
context-length breach are not eligible for fallback and are re-raised
immediately so the correct handler (user or ContextOverflowMapperMiddleware)
can deal with them.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Awaitable, Callable
from langchain.agents.middleware.types import (
AgentMiddleware,
ModelRequest,
ModelResponse,
)
logger = logging.getLogger(__name__)
_ui_emit_fn: Callable[[str, str], None] | None = None
"""UI callback registered by the CLI/TUI entrypoint. ``None`` until set."""
_fallback_chain_lock = threading.Lock()
_fallback_chain: list[tuple[str, str]] = []
"""Ordered list of ``(model_name, provider)`` fallback entries."""
_CONTEXT_LIMIT_PATTERNS: list[str] = [
"context_length_exceeded",
"context length exceeded",
"too many tokens",
"maximum context length",
"output too large",
"context_window_exceeded",
"string_too_long",
"max_tokens_exceeded",
]
"""Substrings that identify a context-length error in provider messages."""
_MALFORMED_REQUEST_PATTERNS: list[str] = [
"invalid_request_error",
"invalid request",
"malformed",
]
"""Substrings that identify a malformed request (client-side bug)."""
_AUTH_ERROR_PATTERNS: list[str] = [
"invalid_api_key",
"authentication",
"permission",
]
"""Substrings that identify auth/permission errors.
These are intentionally *not* treated as non-fallbackable because a different
provider in the chain may have valid credentials."""
def set_ui_emit(fn: Callable[[str, str], None] | None) -> None:
"""Register (or clear) the UI callback for fallback status messages.
Args:
fn: Callable with signature ``fn(text, style)`` where *style* is a
Rich style string (``"yellow"``, ``"red"``, ``"green"``).
Pass ``None`` to unregister.
"""
global _ui_emit_fn
_ui_emit_fn = fn
def _emit(text: str, style: str = "yellow") -> None:
"""Surface a fallback status message to the user.
Dispatches to the registered UI callback when available (TUI mode),
otherwise falls back to the shared Rich console on stdout (CLI mode).
Args:
text: Plain-text message to display.
style: Rich style string applied to the message.
"""
if _ui_emit_fn is not None:
try:
_ui_emit_fn(text, style)
return
except Exception:
pass
from ..stream.console import console
console.print(text, style=style)
def get_fallback_chain() -> list[tuple[str, str]]:
"""Return a snapshot of the current fallback chain.
Returns:
List of ``(model_name, provider)`` tuples in priority order.
"""
with _fallback_chain_lock:
return list(_fallback_chain)
def set_fallback_chain(chain: list[tuple[str, str]]) -> None:
"""Replace the entire fallback chain.
Args:
chain: New list of ``(model_name, provider)`` tuples.
"""
global _fallback_chain
with _fallback_chain_lock:
_fallback_chain = list(chain)
def add_fallback(model: str, provider: str) -> bool:
"""Append a model to the end of the fallback chain.
Args:
model: Short model name (e.g. ``"gpt-5.5"``).
provider: Provider identifier (e.g. ``"openai"``).
Returns:
``True`` if added, ``False`` if the entry was already present.
"""
entry = (model, provider)
with _fallback_chain_lock:
if entry in _fallback_chain:
return False
_fallback_chain.append(entry)
return True
def remove_fallback(model: str) -> bool:
"""Remove all entries matching *model* regardless of provider.
Args:
model: Short model name to remove.
Returns:
``True`` if at least one entry was removed.
"""
global _fallback_chain
with _fallback_chain_lock:
before = len(_fallback_chain)
_fallback_chain = [(m, p) for m, p in _fallback_chain if m != model]
return len(_fallback_chain) < before
def remove_fallback_at(index: int) -> tuple[str, str] | None:
"""Remove the entry at a 0-based index.
Args:
index: Position in the chain (0-based).
Returns:
The removed ``(model, provider)`` tuple, or ``None`` if out of range.
"""
with _fallback_chain_lock:
if 0 <= index < len(_fallback_chain):
return _fallback_chain.pop(index)
return None
def clear_fallbacks() -> None:
"""Remove every entry from the fallback chain."""
global _fallback_chain
with _fallback_chain_lock:
_fallback_chain = []
def serialize_fallback_chain() -> str:
"""Serialize the chain to a config-friendly string.
Returns:
Comma-separated ``"model:provider,model:provider"`` string.
"""
with _fallback_chain_lock:
return ",".join(f"{m}:{p}" for m, p in _fallback_chain)
def load_fallback_chain(raw: str) -> None:
"""Populate the chain from a serialized config string.
Args:
raw: Comma-separated ``"model:provider"`` pairs. Empty or
whitespace-only segments are silently skipped.
"""
global _fallback_chain
chain: list[tuple[str, str]] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
if ":" in part:
model, provider = part.rsplit(":", 1)
chain.append((model.strip(), provider.strip()))
with _fallback_chain_lock:
_fallback_chain = chain
def _is_non_fallbackable(exc: Exception) -> str | None:
"""Determine whether an exception should bypass the fallback chain.
Args:
exc: The exception raised by a model call.
Returns:
A human-readable reason string if the error must *not* trigger
fallback, or ``None`` if fallback should proceed.
"""
from langchain_core.exceptions import ContextOverflowError
if isinstance(exc, ContextOverflowError):
return "context length exceeded"
err_msg = str(exc).lower()
is_400 = "400" in err_msg or "bad request" in err_msg
if is_400 and any(p in err_msg for p in _CONTEXT_LIMIT_PATTERNS):
return "context length exceeded"
if is_400 and any(p in err_msg for p in _MALFORMED_REQUEST_PATTERNS):
return "malformed request (client-side error)"
return None
async def _try_fallbacks(
request: ModelRequest,
invoke: Callable[[ModelRequest], Awaitable[ModelResponse]],
primary_exc: Exception,
) -> ModelResponse:
"""Walk the fallback chain, trying each model until one succeeds.
Shared implementation for both sync and async middleware entry points.
The *invoke* callable is an async function that calls the handler with
a given request — the sync path wraps the synchronous handler in a
trivial coroutine so both paths converge here.
Args:
request: The original model request.
invoke: Async callable that invokes the handler on a request.
primary_exc: The exception raised by the primary model.
Returns:
The ``ModelResponse`` from the first successful fallback.
Raises:
Exception: Re-raises the last exception if all fallbacks fail.
"""
from ..llm.models import get_chat_model
_emit(
f"Primary model failed: {type(primary_exc).__name__}: {primary_exc}",
style="yellow",
)
logger.warning(
"Primary model failed: %s: %s", type(primary_exc).__name__, primary_exc
)
last_exc = primary_exc
for model_name, provider in get_fallback_chain():
_emit(
f" -> Falling back to {model_name} ({provider}) "
f"due to: {type(last_exc).__name__}: {last_exc}",
style="yellow",
)
try:
fallback_model = get_chat_model(model=model_name, provider=provider)
fb_request = request.override(model=fallback_model)
result = await invoke(fb_request)
_emit(
f" Fallback to {model_name} ({provider}) succeeded",
style="green",
)
logger.info("Fallback to %s (%s) succeeded", model_name, provider)
return result
except Exception as fb_exc:
reason = _is_non_fallbackable(fb_exc)
if reason is not None:
_emit(
f" {model_name} hit non-fallbackable error ({reason}) "
f"-- aborting fallback chain",
style="red",
)
raise
last_exc = fb_exc
_emit(
f" x {model_name} also failed: {type(fb_exc).__name__}: {fb_exc}",
style="red",
)
logger.warning(
"Fallback %s (provider=%s) failed: %s: %s",
model_name,
provider,
type(fb_exc).__name__,
fb_exc,
)
_emit(" All fallbacks exhausted -- re-raising last error", style="red")
raise last_exc
def _guard_and_fallback(
primary_exc: Exception,
request: ModelRequest,
invoke: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> Awaitable[ModelResponse]:
"""Check non-fallbackable conditions, then delegate to ``_try_fallbacks``.
Args:
primary_exc: The exception raised by the primary model.
request: The original model request.
invoke: Async callable that invokes the handler on a request.
Returns:
Coroutine that resolves to the fallback ``ModelResponse``.
Raises:
Exception: Re-raises immediately for non-fallbackable errors.
"""
reason = _is_non_fallbackable(primary_exc)
if reason is not None:
_emit(
f"Model error ({reason}) -- not eligible for fallback, re-raising",
style="red",
)
raise primary_exc
return _try_fallbacks(request, invoke, primary_exc)
class ModelFallbackMiddleware(AgentMiddleware):
"""LangChain AgentMiddleware that retries failed model calls on fallbacks.
On each invocation the middleware reads the module-level
``_fallback_chain`` so that ``/model-fallback add`` takes effect
immediately without rebuilding the agent.
Attributes:
name: Middleware identifier used by the framework.
"""
name = "model_fallback"
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
if not _fallback_chain:
return handler(request)
try:
return handler(request)
except Exception as exc:
async def _sync_invoke(r: ModelRequest) -> ModelResponse:
return handler(r)
import asyncio
return asyncio.run(_guard_and_fallback(exc, request, _sync_invoke))
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
if not _fallback_chain:
return await handler(request)
try:
return await handler(request)
except Exception as exc:
return await _guard_and_fallback(exc, request, handler)
+327
View File
@@ -0,0 +1,327 @@
"""Tests for the model fallback middleware.
Covers error classification (_is_non_fallbackable) and the end-to-end
fallback chain behaviour via _try_fallbacks / _guard_and_fallback.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain_core.exceptions import ContextOverflowError
from langchain_core.messages import AIMessage, HumanMessage
from EvoScientist.middleware.model_fallback import (
_guard_and_fallback,
_is_non_fallbackable,
_try_fallbacks,
add_fallback,
clear_fallbacks,
set_ui_emit,
)
from tests.conftest import run_async as _run
# ── Helpers ──────────────────────────────────────────────────────
def _fake_request():
"""Build a minimal ModelRequest stub with an .override() method."""
req = MagicMock()
req.override = MagicMock(side_effect=lambda **kw: req)
req.messages = [HumanMessage(content="hi")]
return req
AI_RESPONSE = AIMessage(content="ok")
@pytest.fixture(autouse=True)
def _clean_chain():
"""Ensure a clean fallback chain and no UI callback for every test."""
clear_fallbacks()
set_ui_emit(None)
yield
clear_fallbacks()
set_ui_emit(None)
# ═════════════════════════════════════════════════════════════════
# 1. _is_non_fallbackable — error classification
# ═════════════════════════════════════════════════════════════════
class TestIsNonFallbackable:
"""Verify which errors block fallback and which allow it."""
# ── Context-length errors: must NOT fallback ────────────────
def test_context_overflow_error_instance(self):
exc = ContextOverflowError("too long")
assert _is_non_fallbackable(exc) == "context length exceeded"
@pytest.mark.parametrize(
"msg",
[
"Error 400: context_length_exceeded",
"Bad Request: context length exceeded in prompt",
"400 too many tokens for this model",
"Bad Request: maximum context length is 128k",
"Error 400: output too large",
"400 Bad Request: context_window_exceeded",
"400: string_too_long",
"Bad Request: max_tokens_exceeded",
],
)
def test_context_limit_400_patterns(self, msg):
assert _is_non_fallbackable(Exception(msg)) == "context length exceeded"
# ── Malformed request errors: must NOT fallback ─────────────
@pytest.mark.parametrize(
"msg",
[
"Error 400: invalid_request_error",
"400 Bad Request: invalid request body",
"400: malformed JSON in request",
],
)
def test_malformed_request_400_patterns(self, msg):
assert (
_is_non_fallbackable(Exception(msg))
== "malformed request (client-side error)"
)
# ── Auth errors: SHOULD fallback (different provider may work) ──
@pytest.mark.parametrize(
"msg",
[
"400 Bad Request: invalid_api_key",
"400: authentication failed",
"400 Bad Request: permission denied",
],
)
def test_auth_errors_are_fallbackable(self, msg):
assert _is_non_fallbackable(Exception(msg)) is None
# ── Server / transient errors: SHOULD fallback ──────────────
@pytest.mark.parametrize(
"msg",
[
"Error 500: internal server error",
"429 Too Many Requests: rate limit exceeded",
"503 Service Unavailable",
"Connection timed out",
"HTTPSConnectionPool: Read timed out",
"502 Bad Gateway",
"overloaded_error: the server is temporarily overloaded",
],
)
def test_server_errors_are_fallbackable(self, msg):
assert _is_non_fallbackable(Exception(msg)) is None
# ── Edge: 400 without a known pattern → fallbackable ────────
def test_400_unknown_pattern_is_fallbackable(self):
assert _is_non_fallbackable(Exception("400: unknown_field 'foo'")) is None
# ── Edge: pattern present but no 400 → fallbackable ─────────
def test_context_pattern_without_400_is_fallbackable(self):
exc = Exception("context_length_exceeded (warning only)")
assert _is_non_fallbackable(exc) is None
def test_malformed_pattern_without_400_is_fallbackable(self):
exc = Exception("invalid_request_error logged for debugging")
assert _is_non_fallbackable(exc) is None
# ═════════════════════════════════════════════════════════════════
# 2. _try_fallbacks — chain walk behaviour
# ═════════════════════════════════════════════════════════════════
class TestTryFallbacks:
"""End-to-end tests for the fallback chain traversal."""
def test_first_fallback_succeeds(self):
"""When the first fallback model works, return its response."""
add_fallback("fb-model", "fb-provider")
req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE)
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = _run(_try_fallbacks(req, invoke, Exception("503 boom")))
assert result is AI_RESPONSE
invoke.assert_awaited_once()
mock_gcm.assert_called_once_with(model="fb-model", provider="fb-provider")
def test_skips_failing_fallback_tries_next(self):
"""When the first fallback fails, try the second."""
add_fallback("fb-bad", "prov-a")
add_fallback("fb-good", "prov-b")
req = _fake_request()
call_count = 0
async def _invoke(r):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("429 rate limited")
return AI_RESPONSE
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = _run(_try_fallbacks(req, _invoke, Exception("503 boom")))
assert result is AI_RESPONSE
assert call_count == 2
def test_all_fallbacks_exhausted_raises_last(self):
"""When every fallback fails, re-raise the last exception."""
add_fallback("fb-a", "prov-a")
add_fallback("fb-b", "prov-b")
req = _fake_request()
last_error = Exception("429 from fb-b")
call_count = 0
async def _invoke(r):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("500 from fb-a")
raise last_error
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
with pytest.raises(Exception, match="429 from fb-b") as exc_info:
_run(_try_fallbacks(req, _invoke, Exception("503 primary")))
assert exc_info.value is last_error
def test_non_fallbackable_in_chain_aborts_immediately(self):
"""A non-fallbackable error from a fallback model aborts the chain."""
add_fallback("fb-a", "prov-a")
add_fallback("fb-b", "prov-b") # should never be reached
req = _fake_request()
async def _invoke(r):
raise Exception("400: context_length_exceeded")
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
with pytest.raises(Exception, match="context_length_exceeded"):
_run(_try_fallbacks(req, _invoke, Exception("503 primary")))
# get_chat_model should only have been called once (for fb-a),
# fb-b should never be reached.
assert mock_gcm.call_count == 1
# ═════════════════════════════════════════════════════════════════
# 3. _guard_and_fallback — pre-check before chain walk
# ═════════════════════════════════════════════════════════════════
class TestGuardAndFallback:
"""Verify that non-fallbackable errors are re-raised before trying the chain."""
def test_context_overflow_raises_immediately(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
with pytest.raises(ContextOverflowError):
_run(_guard_and_fallback(ContextOverflowError("overflow"), req, invoke))
invoke.assert_not_awaited()
def test_malformed_400_raises_immediately(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
with pytest.raises(Exception, match="invalid_request_error"):
_run(
_guard_and_fallback(
Exception("400: invalid_request_error"), req, invoke
)
)
invoke.assert_not_awaited()
def test_server_error_proceeds_to_fallback(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE)
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = _run(_guard_and_fallback(Exception("503 overloaded"), req, invoke))
assert result is AI_RESPONSE
invoke.assert_awaited_once()
def test_auth_error_proceeds_to_fallback(self):
"""Auth errors should try the fallback chain (different provider)."""
add_fallback("fb", "other-prov")
req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE)
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = _run(
_guard_and_fallback(
Exception("400 Bad Request: invalid_api_key"), req, invoke
)
)
assert result is AI_RESPONSE
invoke.assert_awaited_once()
# ═════════════════════════════════════════════════════════════════
# 4. UI emit callback
# ═════════════════════════════════════════════════════════════════
class TestUiEmit:
"""Verify that fallback events are surfaced via the registered callback."""
def test_emit_captures_messages(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE)
messages: list[tuple[str, str]] = []
set_ui_emit(lambda text, style: messages.append((text, style)))
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
_run(_try_fallbacks(req, invoke, Exception("503 down")))
texts = [t for t, _ in messages]
assert any("Primary model failed" in t for t in texts)
assert any("Falling back to fb (prov)" in t for t in texts)
assert any("succeeded" in t for t in texts)
def test_emit_shows_non_fallbackable_rejection(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
messages: list[tuple[str, str]] = []
set_ui_emit(lambda text, style: messages.append((text, style)))
with pytest.raises(ContextOverflowError):
_run(_guard_and_fallback(ContextOverflowError("overflow"), req, invoke))
texts = [t for t, _ in messages]
assert any("not eligible for fallback" in t for t in texts)