Newer
Older
navi-1 / navi / api / routes / health.py
"""Health endpoint — handbook contract (10-platform/health.md).

`status` is the aggregate of the sub-checks (worst of: down > degraded > ok).
Embed/hive failures degrade the service, they don't take it down. The body
keeps the pre-contract `embed`/`hive` payloads at the top level so existing
in-house tooling keeps working (monitors ignore unknown fields).
"""

import time

from fastapi import APIRouter

from navi.config import settings

router = APIRouter(tags=["health"])

# The embed check is a real network call — polls come in every ~30 s, so the
# result is cached short (handbook: cache at most 10 s).
_EMBED_CACHE_TTL = 10.0
_embed_cache: dict = {"checked_at": 0.0, "result": None}


@router.get("/health")
async def health() -> dict:
    embed_status = await _check_embed_cached()
    hive = _hive_status()

    llm = _llm_status()

    embed_check = _embed_check_entry(embed_status)
    hive_check = _hive_check_entry(hive)
    llm_check = _llm_check_entry(llm)

    statuses = [check["status"] for check in (embed_check, hive_check, llm_check)]
    aggregate = "degraded" if "degraded" in statuses else "ok"

    return {
        "status": aggregate,
        "service": "navi",
        "checks": {"embed": embed_check, "hive": hive_check, "llm": llm_check},
        "llm": llm,
        # Legacy payloads (pre-contract) — kept for existing tooling.
        "embed": embed_status,
        "hive": hive,
    }


def _llm_status() -> dict:
    """Which entry serves each route, and what is degraded.

    Reads in-process state only — no network call. The background probe is
    what keeps it current, so this is cheap enough for a 30 s poll.
    """
    try:
        from navi.api.deps import _resolve_container

        backend = _resolve_container().llm_backend
    except Exception:
        backend = None
    if backend is None or not hasattr(backend, "active_report"):
        return {"configured": False}

    active = backend.active_report()
    health = backend.health.snapshot()
    return {
        "configured": True,
        "degraded": any(not info["primary"] for info in active.values()),
        "active": active,
        "targets": health.get("targets", {}),
        "missing_models": health.get("missing_models", []),
    }


def _llm_check_entry(llm: dict) -> dict:
    if not llm.get("configured"):
        return {"status": "ok", "detail": "not configured"}
    if llm.get("degraded"):
        served = ", ".join(
            f"{route}={info['model']}@{info['target']}"
            for route, info in llm.get("active", {}).items()
            if not info.get("primary")
        )
        return {"status": "degraded", "detail": f"running on a backup entry: {served}"}
    return {"status": "ok"}


def _embed_check_entry(embed_status: dict) -> dict:
    """Map the legacy embed probe to a handbook-shaped check entry."""
    if embed_status.get("ok"):
        check = {"status": "ok"}
        if embed_status.get("ms") is not None:
            check["ms"] = embed_status["ms"]
        return check
    return {
        "status": "degraded",
        "detail": embed_status.get("error") or "no_embedding_backend",
    }


def _hive_check_entry(hive: dict) -> dict:
    """Map the swarm registry status to a handbook-shaped check entry."""
    if not hive.get("configured", False):
        return {"status": "ok", "detail": "not configured"}
    if hive.get("error"):
        return {"status": "degraded", "detail": hive["error"]}
    if not hive.get("reachable", False):
        detail = hive.get("last_error") or "hive unreachable"
        return {"status": "degraded", "detail": detail}
    return {"status": "ok"}


@router.get("/health/embed")
async def health_embed() -> dict:
    return await _check_embed_cached()


def _hive_status() -> dict:
    """Swarm registry (hive) reachability as seen by the announce loop."""
    if not settings.hive_url:
        return {"configured": False}
    from navi.swarm import get_announcer

    announcer = get_announcer()
    if announcer is None:
        return {"configured": False, "error": "swarm key missing - announce disabled"}
    return announcer.get_status()


async def _check_embed_cached() -> dict:
    """Embed self-check, cached for up to 10 s (health polls every ~30 s)."""
    now = time.monotonic()
    if (
        _embed_cache["result"] is not None
        and now - _embed_cache["checked_at"] < _EMBED_CACHE_TTL
    ):
        return _embed_cache["result"]

    started = time.perf_counter()
    result = await _check_embed()
    elapsed_ms = int((time.perf_counter() - started) * 1000)
    result.setdefault("ms", None)
    result["ms"] = elapsed_ms

    _embed_cache["checked_at"] = now
    _embed_cache["result"] = result
    return result


async def _check_embed() -> dict:
    """One embed call against the active backend; never raises."""
    backend = None
    try:
        from navi.api.deps import get_memory_store

        mem = get_memory_store()
        backend = getattr(mem, "_embedding_backend", None)
        if backend is None:
            return {"ok": False, "backend": "none", "error": "no_embedding_backend"}
        await backend.embed(
            ["test"], model=settings.embedding_model, route=settings.embedding_route
        )
        return {"ok": True, "backend": getattr(backend, "_host", "unknown"), "error": None}
    except Exception as e:
        host = getattr(backend, "_host", "unknown") if backend else "none"
        return {"ok": False, "backend": host, "error": str(e)}