forked from retoor/devplacepy
feat: add api key auth, devii agent, openai gateway, and admin service management
This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import uuid_utils
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import _user_from_api_key, _user_from_session, get_current_user
|
||||
|
||||
logger = logging.getLogger("devii.router")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
GUEST_COOKIE = "devii_guest"
|
||||
GUEST_COOKIE_MAX_AGE = 31536000
|
||||
|
||||
|
||||
def _service():
|
||||
return service_manager.get_service("devii")
|
||||
|
||||
|
||||
def _bearer_key(headers) -> str:
|
||||
key = headers.get("x-api-key", "")
|
||||
if key:
|
||||
return key.strip()
|
||||
scheme, _, credentials = headers.get("authorization", "").partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
return credentials.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_ws_owner(websocket: WebSocket):
|
||||
user = _user_from_session(websocket)
|
||||
if not user:
|
||||
key = _bearer_key(websocket.headers)
|
||||
if key:
|
||||
user = _user_from_api_key(key)
|
||||
if user:
|
||||
return "user", user["uid"], user.get("username", ""), user.get("api_key", "")
|
||||
guest_id = websocket.cookies.get(GUEST_COOKIE) or uuid_utils.uuid7().hex
|
||||
return "guest", guest_id, "guest", ""
|
||||
|
||||
|
||||
def _owner_from_request(request: Request):
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return "user", user["uid"], user.get("username", "")
|
||||
guest_id = request.cookies.get(GUEST_COOKIE) or ""
|
||||
return "guest", guest_id, "guest"
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def devii_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
response = templates.TemplateResponse(
|
||||
request, "devii.html", {"request": request, "user": user, "page_title": "Devii", "meta_robots": "noindex,nofollow"}
|
||||
)
|
||||
if not user and not request.cookies.get(GUEST_COOKIE):
|
||||
response.set_cookie(GUEST_COOKIE, uuid_utils.uuid7().hex, httponly=True, samesite="lax", max_age=GUEST_COOKIE_MAX_AGE)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/session")
|
||||
async def devii_session(request: Request):
|
||||
user = get_current_user(request)
|
||||
svc = _service()
|
||||
payload = {
|
||||
"loggedIn": bool(user),
|
||||
"username": user.get("username") if user else "guest",
|
||||
"enabled": bool(svc and svc.is_enabled()),
|
||||
"guestsEnabled": bool(svc and svc.guests_enabled()),
|
||||
}
|
||||
response = JSONResponse(payload)
|
||||
if not user and not request.cookies.get(GUEST_COOKIE):
|
||||
response.set_cookie(GUEST_COOKIE, uuid_utils.uuid7().hex, httponly=True, samesite="lax", max_age=GUEST_COOKIE_MAX_AGE)
|
||||
return response
|
||||
|
||||
|
||||
def _safe_next(value: str) -> str:
|
||||
if value.startswith("/") and not value.startswith("//"):
|
||||
return value
|
||||
return "/"
|
||||
|
||||
|
||||
@router.get("/adopt")
|
||||
async def devii_adopt(request: Request):
|
||||
# The terminal's agent logged in; adopt the real session it minted into the browser so both
|
||||
# share one session, then return to where the user was.
|
||||
target = _safe_next(request.query_params.get("next", "/"))
|
||||
response = RedirectResponse(target, status_code=303)
|
||||
svc = _service()
|
||||
if svc is None:
|
||||
return response
|
||||
owner_kind, owner_id, _ = _owner_from_request(request)
|
||||
session = svc.hub().find(owner_kind, owner_id) if owner_id else None
|
||||
token = session.take_pending_session() if session else None
|
||||
if token:
|
||||
max_age = max(1, get_int_setting("session_remember_days", 30)) * 86400
|
||||
response.set_cookie("session", token, max_age=max_age, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
async def devii_usage(request: Request):
|
||||
svc = _service()
|
||||
owner_kind, owner_id, _ = _owner_from_request(request)
|
||||
if svc is None:
|
||||
return JSONResponse({"spent_24h": 0.0, "limit": 0.0, "turns_today": 0})
|
||||
spent = svc.spent_24h(owner_kind, owner_id) if owner_id else 0.0
|
||||
turns = svc.hub().ledger.turns_24h(owner_kind, owner_id) if owner_id else 0
|
||||
return JSONResponse({
|
||||
"spent_24h": round(spent, 6),
|
||||
"limit": svc.daily_limit_for(owner_kind),
|
||||
"turns_today": turns,
|
||||
"owner_kind": owner_kind,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/clippy/ai/chat")
|
||||
async def clippy_proxy(request: Request):
|
||||
svc = _service()
|
||||
if svc is None or not svc.is_enabled():
|
||||
return JSONResponse({"error": "Devii is unavailable"}, status_code=503)
|
||||
cfg = svc.effective_config()
|
||||
body = await request.body()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cfg.get("devii_ai_key"):
|
||||
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
|
||||
async with httpx.AsyncClient(timeout=45.0) as client:
|
||||
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)
|
||||
return JSONResponse(
|
||||
content=upstream.json() if upstream.headers.get("content-type", "").startswith("application/json") else {"raw": upstream.text},
|
||||
status_code=upstream.status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def devii_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
svc = _service()
|
||||
if svc is None or not svc.is_enabled():
|
||||
await websocket.close(code=1013)
|
||||
return
|
||||
if not service_manager.owns_lock():
|
||||
await websocket.close(code=1013)
|
||||
return
|
||||
owner_kind, owner_id, username, api_key = _resolve_ws_owner(websocket)
|
||||
if owner_kind == "guest" and not svc.guests_enabled():
|
||||
await websocket.send_json({"type": "error", "text": "Guest access to Devii is disabled."})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
session = svc.hub().get_or_create(owner_kind, owner_id, username, api_key, svc.instance_base_url())
|
||||
session.attach(websocket)
|
||||
try:
|
||||
await session.send_bootstrap(websocket)
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
kind = data.get("type")
|
||||
if kind == "input":
|
||||
text = str(data.get("text", "")).strip()
|
||||
if not text:
|
||||
continue
|
||||
limit = svc.daily_limit_for(owner_kind)
|
||||
spent = svc.spent_24h(owner_kind, owner_id)
|
||||
if spent >= limit:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"text": f"Daily limit reached (${spent:.4f} of ${limit:.2f}). Try again later.",
|
||||
})
|
||||
continue
|
||||
session.spawn_turn(text)
|
||||
elif kind == "reset":
|
||||
await session.reset_conversation()
|
||||
elif kind in ("avatar_result", "client_result"):
|
||||
session.resolve_query(str(data.get("id", "")), data.get("result"))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - never let the socket loop crash the worker
|
||||
logger.exception("Devii websocket loop failed for %s/%s", owner_kind, owner_id)
|
||||
finally:
|
||||
session.detach(websocket)
|
||||
Reference in New Issue
Block a user