Newer
Older
navi-1 / navi / api / routes / sessions / common.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 1 day ago 6 KB api: split the sessions routes into a package
"""Request models and shared helpers — owner scoping, profile/route info, payloads."""

from typing import Any

from fastapi import HTTPException
from pydantic import BaseModel

from navi.auth import User
from navi.config import settings
from navi.core import BackendRegistry, ProfileRegistry
from navi.exceptions import ProfileNotFound


class CreateSessionRequest(BaseModel):
    profile_id: str | None = None


class PinSessionRequest(BaseModel):
    pinned: bool


class RenameSessionRequest(BaseModel):
    # Empty string means "regenerate from session content" (the client sends
    # it when the user cleared the title and left the edit field).
    name: str = ""

def _session_owner(user) -> str | None:
    """Owner id for session create/list.

    In local mode (auth disabled) the anonymous user is a separate user with
    user_id=None — not an alias of every other user — so sessions must be
    persisted ownerless and listings must scope to NULL-owner rows only.
    """
    return user.id if settings.navi_auth_enabled else None


def _session_admin(user) -> bool:
    """Listing-scope admin flag — only meaningful when auth is enabled.

    The local-mode anonymous user is nominally 'admin' so debug/admin
    endpoints keep working, but the admin flag must never widen the chat
    session listings beyond NULL-owner rows there.
    """
    return settings.navi_auth_enabled and (
        user.role == "admin" or user.has_permission("navi.sessions.read_all")
    )


def _profile_of(profiles: ProfileRegistry, profile_id: str | None):
    """Profile or None — a deleted profile must not break the session head."""
    if not profile_id:
        return None
    try:
        return profiles.get(profile_id)
    except ProfileNotFound:
        return None


def _backend_for(profile, backends: BackendRegistry):
    key = getattr(profile, "llm_backend", "ollama") if profile is not None else "ollama"
    try:
        return backends.get(key)
    except KeyError:
        return None


def _effective_window(profile, backend) -> int:
    """Context window the agent holds this profile's prompts to.

    Same precedence as ``Agent._window_for``: the route's active entry wins,
    then the profile's own value, then the global default. The session head
    used to report the global ``OLLAMA_NUM_CTX`` even when the route serving
    the session declared another window — the client then drew a percentage
    against a denominator the agent never used.
    """
    if profile is not None:
        active_window = getattr(backend, "active_window", None)
        if callable(active_window):
            try:
                window = active_window(getattr(profile, "llm_route", None))
            except Exception:  # pragma: no cover - a backend may not know profiles
                window = None
            if window:
                return window
        own = getattr(profile, "context_window", None)
        if own:
            return own
    return settings.ollama_num_ctx


def _llm_info(profile, backend) -> dict:
    """What the route engine is doing for this session's profile.

    In-process state only (the background probe keeps it current): the active
    entry — model, target, host, window — and the chain with each target's
    health. ``configured: false`` means the deployment has no ``llm.json``
    routes; the profile's model list is then all there is to show.
    """
    route_id = getattr(profile, "llm_route", None) if profile is not None else None
    models = list(getattr(profile, "model", None) or []) if profile is not None else []
    info: dict[str, Any] = {
        "configured": False,
        "route": route_id,
        "models": models,
        "active": None,
        "chain": [],
    }
    if backend is None or not hasattr(backend, "active_report"):
        return info

    info["configured"] = True
    config = getattr(backend, "config", None)
    route = config.route(route_id) if config is not None else None
    report = backend.active_report() or {}
    active = report.get(route_id) if route_id else None

    states: dict[str, dict] = {}
    missing: set[tuple[str, str]] = set()
    health = getattr(backend, "health", None)
    if health is not None:
        snapshot = health.snapshot()
        states = snapshot.get("targets", {})
        missing = {(m["target"], m["model"]) for m in snapshot.get("missing_models", [])}

    chain: list[dict] = []
    if route is not None:
        for entry in route.entries:
            state = states.get(entry.target.name, {})
            item = {
                "model": entry.model,
                "target": entry.target.name,
                "host": entry.target.host,
                "primary": entry is route.primary,
                "state": "missing" if (entry.target.name, entry.model) in missing
                else state.get("state", "ok"),
            }
            if state.get("reason"):
                item["reason"] = state["reason"]
            chain.append(item)
    info["chain"] = chain

    if active is not None:
        host = next(
            (e["host"] for e in chain
             if e["model"] == active["model"] and e["target"] == active["target"]),
            "",
        )
        info["active"] = {
            "model": active["model"],
            "target": active["target"],
            "host": host,
            "primary": bool(active.get("primary", True)),
            "context_window": _effective_window(profile, backend),
        }
    return info

def _recall_payload(recall: Any) -> dict | None:
    """The recall as the client reads it — one shape everywhere."""
    if recall is None:
        return None
    return {
        "id": recall.id,
        "call_type": recall.call_type,
        "trigger_at": recall.trigger_at.isoformat(),
        "interval_seconds": recall.interval_seconds,
        "internal_comment": recall.internal_comment,
        "additional_context_message": recall.additional_context_message,
        "status": recall.status,
        "created_at": recall.created_at.isoformat(),
    }

def _require_share_owner(session, user: User) -> None:
    """Only the owner (or an admin) may hand out a chat's history.

    Deliberately stricter than ``check_session_access``: a read-all permission
    lets staff *read* other users' sessions, not distribute them.
    """
    if not settings.navi_auth_enabled:
        if session.user_id is not None:
            raise HTTPException(status_code=403, detail="Access denied")
        return
    if session.user_id is not None and session.user_id == user.id:
        return
    if user.role == "admin":
        return
    raise HTTPException(status_code=403, detail="Access denied")