"""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")