"""Health of LLM targets, and the background probe that switches back.
The old engine had a one-way door: a dead server got blacklisted for a fixed
TTL and nothing ever checked whether it came back — on production the TTL was
never even populated, because the blacklists were disabled for single-server
setups. Recovery was therefore a restart.
Here the engine records *why* an entry failed (network, quota, auth, 5xx,
missing model) and when it may be retried. A single background task probes the
degraded targets once per interval with a short timeout; when the probe says a
target is healthy again the next user turn starts on the primary entry — the
probe never interrupts a running turn.
The probe costs nothing while everything is healthy: it only looks at targets
that are not ``ok``.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
import ollama as ollama_client
import structlog
from navi.exceptions import (
LLMAuthError,
LLMConnectionError,
LLMModelNotFoundError,
LLMQuotaExceededError,
LLMServerUnavailableError,
)
from .config import LLMConfig, RouteEntry, Target
log = structlog.get_logger()
# An auth failure is not transient, but the key can be rotated in the
# environment without a restart — so re-check it, just not every minute.
_AUTH_RECHECK_SEC = 300.0
@dataclass
class TargetHealth:
state: str = "ok" # ok | down | cooling | auth
reason: str = "" # network | quota | server | auth
since: float = 0.0 # monotonic, when it went non-ok
next_probe: float = 0.0 # monotonic, earliest retry
detail: str = ""
@dataclass
class ModelHealth:
missing: bool = False
next_probe: float = 0.0
detail: str = ""
class RouteHealth:
"""Per-target and per-(target, model) state, plus the probe task."""
def __init__(self, config: LLMConfig) -> None:
self._config = config
self._targets: dict[str, TargetHealth] = {
name: TargetHealth() for name in config.targets
}
self._models: dict[tuple[str, str], ModelHealth] = {}
self._clients: dict[str, ollama_client.AsyncClient] = {}
self._task: asyncio.Task | None = None
# Called with (target_name, state) after a probe changes the state —
# lets the container log a recovery without importing logging here.
self.on_change = None
# ── queries ──────────────────────────────────────────────────────────
def _target_state(self, target: Target) -> TargetHealth:
return self._targets.setdefault(target.name, TargetHealth())
def target_usable(self, target: Target, now: float | None = None) -> bool:
"""May a request go to this target right now?
A target that is cooling is tried again once its earliest retry time
has passed even if the background probe has not run yet: refusing a
request that could have worked is worse than one extra failed attempt.
"""
h = self._target_state(target)
if h.state == "ok":
return True
return (time.monotonic() if now is None else now) >= h.next_probe
def entry_usable(self, entry: RouteEntry, now: float | None = None) -> bool:
if not self.target_usable(entry.target, now):
return False
m = self._models.get((entry.target.name, entry.model))
if m is None:
return True
if not m.missing:
return True
return (time.monotonic() if now is None else now) >= m.next_probe
# ── recorded by the engine ───────────────────────────────────────────
def reset(self) -> None:
"""Forget every recorded failure (admin action after a config fix)."""
for h in self._targets.values():
h.state, h.reason, h.detail = "ok", "", ""
h.since = h.next_probe = 0.0
self._models.clear()
log.info("llm.health_reset")
def report_success(self, entry: RouteEntry) -> None:
h = self._target_state(entry.target)
was = h.state
h.state, h.reason, h.detail, h.next_probe = "ok", "", "", 0.0
self._models.pop((entry.target.name, entry.model), None)
if was != "ok":
log.info("llm.target_recovered", target=entry.target.name, previous=was)
self._notify(entry.target.name, "ok")
def report_failure(self, entry: RouteEntry, exc: Exception) -> None:
now = time.monotonic()
interval = self._config.probe_interval_sec
if isinstance(exc, LLMModelNotFoundError):
self._models[(entry.target.name, entry.model)] = ModelHealth(
missing=True, next_probe=now + interval, detail=str(exc)
)
log.warning(
"llm.model_missing", target=entry.target.name, model=entry.model
)
return
if isinstance(exc, LLMQuotaExceededError):
delay = max(getattr(exc, "retry_after", None) or 0.0, interval)
state, reason = "cooling", "quota"
elif isinstance(exc, LLMAuthError):
delay, state, reason = _AUTH_RECHECK_SEC, "auth", "auth"
elif isinstance(exc, LLMServerUnavailableError):
delay, state, reason = interval, "cooling", "server"
elif isinstance(exc, LLMConnectionError):
delay, state, reason = interval, "down", "network"
else:
# Unknown failures are not worth blacklisting a whole host for.
return
h = self._target_state(entry.target)
changed = h.state != state
h.state, h.reason, h.detail = state, reason, str(exc)
h.next_probe = now + delay
if changed:
h.since = now
log.warning(
"llm.target_degraded",
target=entry.target.name,
state=state,
reason=reason,
detail=str(exc)[:300],
)
self._notify(entry.target.name, state)
def _notify(self, target: str, state: str) -> None:
if self.on_change is None:
return
try:
self.on_change(target, state)
except Exception: # a listener must never break the LLM path
log.debug("llm.health.listener_failed", exc_info=True)
# ── snapshot for /health ─────────────────────────────────────────────
def snapshot(self) -> dict:
now = time.monotonic()
targets = {}
for name, h in self._targets.items():
entry = {"state": h.state}
if h.state != "ok":
entry["reason"] = h.reason
entry["retry_in_sec"] = max(0, round(h.next_probe - now))
if h.detail:
entry["detail"] = h.detail[:200]
targets[name] = entry
missing = [
{"target": target, "model": model, "detail": state.detail[:200]}
for (target, model), state in self._models.items()
if state.missing
]
return {"targets": targets, "missing_models": missing}
# ── background probe ─────────────────────────────────────────────────
def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._task = asyncio.create_task(self._loop(), name="llm-health-probe")
log.info(
"llm.health_probe_started",
interval_sec=self._config.probe_interval_sec,
targets=list(self._config.targets),
)
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception):
pass
self._task = None
for client in self._clients.values():
try:
await client._client.aclose()
except Exception:
pass
self._clients.clear()
async def _loop(self) -> None:
interval = self._config.probe_interval_sec
while True:
try:
await asyncio.sleep(interval)
await self.probe_once()
except asyncio.CancelledError:
raise
except Exception:
log.warning("llm.health_probe_failed", exc_info=True)
async def probe_once(self) -> None:
"""Probe every target that is not healthy (and every missing model)."""
now = time.monotonic()
due = [
target
for name, target in self._config.targets.items()
if (h := self._targets.get(name)) is not None and h.state != "ok"
and now >= h.next_probe
]
missing_targets = {
t for (t, _), m in self._models.items() if m.missing and now >= m.next_probe
}
for name in missing_targets:
target = self._config.targets.get(name)
if target is not None and target not in due:
due.append(target)
if not due:
return
await asyncio.gather(*(self._probe(target) for target in due))
async def _probe(self, target: Target) -> None:
timeout = self._config.probe_timeout_sec
client = self._client(target)
h = self._target_state(target)
try:
await asyncio.wait_for(client.list(), timeout=timeout)
except TimeoutError:
self._mark(target, "down", "network", f"probe timed out after {timeout}s")
return
except Exception as e:
state, reason = _classify_probe_error(e)
self._mark(target, state, reason, str(e))
return
# Reachable. Now the model-level questions.
models = {
entry.model
for route in self._config.routes.values()
for entry in route.entries
if entry.target.name == target.name
}
for model in models:
key = (target.name, model)
current = self._models.get(key)
if current is None or not current.missing:
continue
try:
await asyncio.wait_for(client.show(model), timeout=timeout)
except Exception as e:
current.next_probe = time.monotonic() + self._config.probe_interval_sec
current.detail = str(e)
continue
self._models.pop(key, None)
log.info("llm.model_recovered", target=target.name, model=model)
# A quota failure can only be checked by asking for a token: `list()`
# still answers while generation is blocked.
if h.state == "cooling" and h.reason == "quota":
entry = next(
(
e
for route in self._config.routes.values()
for e in route.entries
if e.target.name == target.name
),
None,
)
if entry is not None:
try:
await asyncio.wait_for(
client.chat(
model=entry.model,
messages=[{"role": "user", "content": "ok"}],
options={"num_predict": 1},
stream=False,
),
timeout=timeout,
)
except Exception as e:
self._mark(target, "cooling", "quota", str(e))
return
h.state, h.reason, h.detail, h.next_probe = "ok", "", "", 0.0
h.since = 0.0
log.info("llm.target_recovered", target=target.name, previous="probe")
self._notify(target.name, "ok")
def _mark(self, target: Target, state: str, reason: str, detail: str) -> None:
h = self._target_state(target)
changed = h.state != state
h.state, h.reason, h.detail = state, reason, detail
h.next_probe = time.monotonic() + self._config.probe_interval_sec
if changed:
log.warning(
"llm.target_degraded", target=target.name, state=state,
reason=reason, detail=detail[:300],
)
self._notify(target.name, state)
def _client(self, target: Target) -> ollama_client.AsyncClient:
client = self._clients.get(target.name)
if client is None:
headers = (
{"Authorization": f"Bearer {target.api_key}"} if target.api_key else None
)
client = ollama_client.AsyncClient(
host=target.host, headers=headers, timeout=self._config.probe_timeout_sec
)
self._clients[target.name] = client
return client
def _classify_probe_error(e: Exception) -> tuple[str, str]:
status = getattr(e, "status_code", None)
if status in (401, 403):
return "auth", "auth"
if status == 429:
return "cooling", "quota"
if isinstance(status, int) and 500 <= status < 600:
return "cooling", "server"
if isinstance(e, (ollama_client.RequestError, TimeoutError)):
return "down", "network"
return "down", "network"