Add read-only companion endpoints (ping/info/owner-scoped models) (#863)
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.
This commit is contained in:
20
companion/README.md
Normal file
20
companion/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Companion bridge (read-only)
|
||||
|
||||
A thin, additive layer so a LAN client can discover what an Odysseus server
|
||||
offers, without duplicating any LLM logic. Reachable with either a logged-in
|
||||
cookie session or a Bearer `ody_` API token (auth is enforced globally by
|
||||
`AuthMiddleware`).
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| 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, the same rule as `owner_filter`. It
|
||||
never returns API-key material. The owner rule lives in two pure, tested helpers
|
||||
(`token_owner`, `owner_can_see`) — see `tests/test_companion_readonly.py`.
|
||||
|
||||
This module is intentionally read-only. Pairing/token-minting, token-owner
|
||||
session attribution, and any mutation endpoints are proposed in separate PRs.
|
||||
10
companion/__init__.py
Normal file
10
companion/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Odysseus companion bridge — additive, read-only LAN endpoints.
|
||||
|
||||
Exposes /api/companion/ping, /info, and an owner-scoped /models so a LAN client
|
||||
can discover what a server offers. No new LLM logic; auth is enforced by the
|
||||
existing AuthMiddleware. See companion/README.md.
|
||||
"""
|
||||
|
||||
from companion.routes import setup_companion_routes
|
||||
|
||||
__all__ = ["setup_companion_routes"]
|
||||
124
companion/routes.py
Normal file
124
companion/routes.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user