First, smallest cut of a LAN companion bridge (split out of #855 per review): a thin, additive, read-only layer so a LAN client can discover what a server offers. No new LLM logic; auth is enforced by the existing AuthMiddleware. - GET /api/companion/ping -- cheap auth-validated health check - GET /api/companion/info -- server identity + capability flags - GET /api/companion/models -- the CALLER's own model endpoints /models scopes to the caller's real owner (the token's owner for bearer callers) plus legacy null-owner shared rows, mirroring owner_filter, and never returns api_key material. The owner rule lives in two pure helpers (token_owner, owner_can_see) with direct tests proving a token for owner A cannot see owner B's rows and that null-owner rows don't widen access.
125 lines
4.9 KiB
Python
125 lines
4.9 KiB
Python
"""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
|