"""Companion bridge — read-only endpoints (/api/companion/*). A thin, additive layer so a LAN client (e.g. a phone) can discover what a server offers without duplicating any LLM logic. This module is intentionally read-only: it exposes a cheap health check, server identity, and the caller's own model list. Pairing/token-minting and any mutation live in separate changes. Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here means the caller is authenticated by either a cookie session or a Bearer `ody_` API token. """ from fastapi import APIRouter, Request from src.auth_helpers import get_current_user def token_owner(request: Request) -> str | None: """The real owner to attribute a request to, for read-scoping. Cookie sessions resolve to the logged-in username via get_current_user. Bearer-token callers come through as the sandboxed pseudo-user "api"; their real owner is stamped on request.state.api_token_owner by the auth middleware. Returns None when no owner can be resolved. """ if getattr(request.state, "api_token", False): return getattr(request.state, "api_token_owner", None) return get_current_user(request) def owner_can_see(row_owner, owner) -> bool: """Owner-scope rule for read endpoints. A caller sees a row when it is their own, or when it is a legacy null-owner ("shared") row. A caller must NEVER see another owner's row. Mirrors the `owner_filter` rule used elsewhere, expressed as a pure predicate so it can be tested directly and used as a defensive in-Python check alongside the SQL filter. """ return row_owner is None or row_owner == owner def setup_companion_routes() -> APIRouter: router = APIRouter(prefix="/api/companion", tags=["companion"]) @router.get("/ping") def ping(request: Request): """Cheap, auth-validated health check. A 200 with ok=true confirms the host/port and credential are valid; middleware returns 401 otherwise.""" from core.constants import APP_VERSION return { "ok": True, "name": "odysseus", "version": APP_VERSION, "auth": "token" if getattr(request.state, "api_token", False) else "session", } @router.get("/info") def info(request: Request): """Server identity + coarse capability flags. `owner` is the caller's own identity (the token's owner for bearer callers).""" from core.constants import APP_VERSION return { "name": "odysseus", "version": APP_VERSION, "owner": token_owner(request), "capabilities": {"chat": True, "streaming": True}, } @router.get("/models") def models(request: Request): """LLM model endpoints the CALLER can use. The stock /api/models route scopes to get_current_user, which for a bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we scope to the token's real owner instead, plus legacy null-owner shared rows -- the same rule as owner_filter. Read-only; never returns api_key material. """ import json as _json from core.database import SessionLocal, ModelEndpoint from src.endpoint_resolver import build_chat_url owner = token_owner(request) out = [] db = SessionLocal() try: q = db.query(ModelEndpoint).filter( ModelEndpoint.is_enabled == True, # noqa: E712 (ModelEndpoint.model_type == "llm") | (ModelEndpoint.model_type == None), # noqa: E711 ) if owner: q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711 for ep in q.all(): # Defence in depth: never emit a row the owner rule rejects, even # if the SQL filter above were ever loosened. if not owner_can_see(ep.owner, owner): continue try: model_ids = _json.loads(ep.cached_models) if ep.cached_models else [] except (ValueError, TypeError): model_ids = [] try: hidden = set(_json.loads(ep.hidden_models)) if ep.hidden_models else set() except (ValueError, TypeError): hidden = set() model_ids = [m for m in model_ids if m not in hidden] try: chat_url = build_chat_url(ep.base_url) except Exception: chat_url = ep.base_url out.append({ "endpoint_id": ep.id, "name": ep.name, "endpoint_url": chat_url, "models": model_ids, "supports_tools": ep.supports_tools, }) finally: db.close() return {"endpoints": out} return router