"""Admin API — settings, llm logs, users, stats, test endpoints, icon upload.""" from __future__ import annotations import os import time import uuid from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_admin from app.config import get_settings from app.core.embeddings import ( HashEmbedder, OpenAIEmbedder, build_hash_embedder, build_openai_embedder, ) from app.core.llm import LlmClient from app.core.logging import get_logger from app.core.qdrant_client import init_qdrant_collections from app.core.rag import reset_embedder_cache from app.core.settings_service import ( DEFAULT_SETTINGS, SECRET_KEYS, get_all_settings, mask_secret, set_setting, ) from app.db import get_db from app.models import LlmCallLog, User from app.schemas import LlmLogDetail, LlmLogOut, SettingsPatchRequest _logger = get_logger(__name__) router = APIRouter(prefix="/api/admin", tags=["admin"]) # --------------------------------------------------------------------------- # # Admin recovery — create a new admin when all existing admins lost access. # This endpoint is NOT behind require_admin (it's for recovery). It requires # the admin.setup_token from settings, which is printed on every startup. # --------------------------------------------------------------------------- # @router.post("/recover", response_model=dict) async def recover_admin( body: dict, db: AsyncSession = Depends(get_db), ) -> dict: """Create a new admin user using the admin setup token. This endpoint is for disaster recovery when all existing admins have lost access. It requires the `admin.setup_token` (printed on backend startup) and creates a new admin user. Body: {token, email, username, password} """ from app.core.security import hash_password, validate_password_strength from app.core.settings_service import get_admin_setup_token from app.models import User as UserModel from sqlalchemy import or_ token = body.get("token", "") expected_token = await get_admin_setup_token(db) if token != expected_token: raise HTTPException(403, "invalid_admin_token") email = body.get("email", "").strip() username = body.get("username", "").strip() password = body.get("password", "") if not email or not username or not password: raise HTTPException(400, "email, username, and password are required") errors = validate_password_strength(password) if errors: raise HTTPException(400, errors[0]) existing = ( await db.execute( select(UserModel).where( or_(UserModel.email == email, UserModel.username == username) ) ) ).scalar_one_or_none() if existing is not None: if existing.email == email: raise HTTPException(400, "email_already_exists") raise HTTPException(400, "username_already_exists") user = UserModel( email=email, username=username, password_hash=hash_password(password), is_admin=True, is_active=True, ) db.add(user) await db.commit() await db.refresh(user) return { "ok": True, "id": str(user.id), "email": user.email, "username": user.username, "is_admin": user.is_admin, } # --------------------------------------------------------------------------- # # Settings # --------------------------------------------------------------------------- # @router.get("/settings") async def get_settings_endpoint( db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: settings = await get_all_settings(db) # Mask secrets out = {k: mask_secret(k, v) for k, v in settings.items()} return {"settings": out, "descriptions": {k: s["description"] for k, s in DEFAULT_SETTINGS.items()}} @router.patch("/settings") async def patch_settings_endpoint( body: SettingsPatchRequest, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: updated = {} for k, v in body.settings.items(): # Don't update secret keys if the masked value was sent back unchanged if k in SECRET_KEYS and isinstance(v, str) and ("…" in v or v == "****"): continue await set_setting(db, k, v) updated[k] = mask_secret(k, v) # Clear embedder cache so new settings take effect reset_embedder_cache() return {"updated": updated} # --------------------------------------------------------------------------- # # LLM logs # --------------------------------------------------------------------------- # @router.get("/llm-logs") async def list_llm_logs( world_id: uuid.UUID | None = None, stage: str | None = None, status_filter: str | None = None, page: int = 1, per_page: int = 50, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: stmt = select(LlmCallLog) if world_id: stmt = stmt.where(LlmCallLog.world_id == world_id) if stage: stmt = stmt.where(LlmCallLog.stage == stage) if status_filter: stmt = stmt.where(LlmCallLog.status == status_filter) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page) rows = (await db.execute(stmt)).scalars().all() items = [] for r in rows: d = LlmLogOut.model_validate(r).model_dump(mode="json") d["world_id"] = str(r.world_id) if r.world_id else None items.append(d) return { "items": items, "total": total, "page": page, "per_page": per_page, } @router.get("/llm-logs/{log_id}", response_model=LlmLogDetail) async def get_llm_log( log_id: uuid.UUID, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> LlmCallLog: log = ( await db.execute(select(LlmCallLog).where(LlmCallLog.id == log_id)) ).scalar_one_or_none() if log is None: raise HTTPException(404, "not_found") return log # --------------------------------------------------------------------------- # # Users # --------------------------------------------------------------------------- # @router.get("/users") async def list_users( db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: rows = (await db.execute(select(User).order_by(User.created_at.desc()))).scalars().all() return {"items": [ {"id": str(u.id), "email": u.email, "username": u.username, "is_admin": u.is_admin, "is_active": u.is_active, "created_at": u.created_at.isoformat(), "last_login_at": u.last_login_at.isoformat() if u.last_login_at else None} for u in rows ]} @router.patch("/users/{user_id}") async def patch_user( user_id: uuid.UUID, body: dict, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: user = ( await db.execute(select(User).where(User.id == user_id)) ).scalar_one_or_none() if user is None: raise HTTPException(404, "not_found") if "is_admin" in body: user.is_admin = bool(body["is_admin"]) if "is_active" in body: user.is_active = bool(body["is_active"]) await db.commit() return {"id": str(user.id), "is_admin": user.is_admin, "is_active": user.is_active} @router.delete("/worlds/{world_id}", status_code=200) async def hard_delete_world( world_id: uuid.UUID, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: """Hard-delete a world (cascade deletes all related entities, steps, logs). Also cleans up Qdrant points for the world (best-effort). """ from app.models import World from app.core.qdrant_client import cleanup_world_points world = ( await db.execute(select(World).where(World.id == world_id)) ).scalar_one_or_none() if world is None: raise HTTPException(404, "not_found") await db.delete(world) await db.commit() # Best-effort Qdrant cleanup try: await cleanup_world_points(str(world_id)) except Exception as e: # noqa: BLE001 _logger.warning("qdrant_cleanup_failed", world_id=str(world_id), error=str(e)) return {"ok": True, "deleted": str(world_id)} # --------------------------------------------------------------------------- # # Stats # --------------------------------------------------------------------------- # @router.get("/stats") async def stats( db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: from app.models import Step, World users_count = (await db.execute(select(func.count(User.id)))).scalar_one() # Active worlds (exclude archived) active_worlds = ( await db.execute( select(func.count(World.id)).where(World.status != "archived") ) ).scalar_one() archived_worlds = ( await db.execute( select(func.count(World.id)).where(World.status == "archived") ) ).scalar_one() total_worlds = active_worlds + archived_worlds steps_count = (await db.execute(select(func.count(Step.id)))).scalar_one() avg_latency = ( await db.execute(select(func.avg(LlmCallLog.latency_ms))) ).scalar_one() return { "users": users_count, "worlds": active_worlds, "worlds_total": total_worlds, "worlds_archived": archived_worlds, "steps": steps_count, "avg_llm_latency_ms": float(avg_latency) if avg_latency else 0, } # --------------------------------------------------------------------------- # # LLM model list — fetch available models from the LLM provider # --------------------------------------------------------------------------- # @router.post("/llm/models") async def list_llm_models( api_url: str | None = None, api_key: str | None = None, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: """Fetch the list of available models from an OpenAI-compatible API. Returns {ok: true, models: ["model1", "model2", ...]} on success. Returns {ok: false, error: {...}} on failure (no auto-fetch available). """ import httpx settings = await get_all_settings(db) api_url = _resolve(api_url, settings.get("llm.api_url", "")) api_key = _resolve(api_key, settings.get("llm.api_key", "")) if not api_url: return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, "models": []} try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get( f"{api_url.rstrip('/')}/models", headers={"Authorization": f"Bearer {api_key}"} if api_key else {}, ) if resp.status_code >= 400: return {"ok": False, "error": {"code": "api_error", "message": f"HTTP {resp.status_code}: {resp.text[:200]}"}, "models": []} data = resp.json() models = [] for m in data.get("data", []): mid = m.get("id") or m.get("name") if mid: models.append(mid) models.sort() return {"ok": True, "models": models, "count": len(models)} except Exception as e: # noqa: BLE001 return {"ok": False, "error": {"code": "connection_failed", "message": str(e)}, "models": []} # --------------------------------------------------------------------------- # # Helpers for test endpoints # --------------------------------------------------------------------------- # def _is_masked(value: str | None) -> bool: """Detect masked secret values (contain '…' or are exactly '****'). The admin GET /settings endpoint masks secret keys before sending them to the client. If the client sends a masked value back to a test endpoint (because it pre-filled the form from the masked settings response), we must ignore it and fall back to the raw value from the DB. """ if not value: return False return "…" in value or value == "****" def _resolve(value: str | None, fallback: str) -> str: """Use `value` if it's a non-empty, non-masked string; otherwise use fallback.""" if value and not _is_masked(value): return value return fallback or "" # --------------------------------------------------------------------------- # # Test endpoints — LLM, embeddings, embeddings probe dimension # All test endpoints accept query params AND fall back to DB-stored settings. # Masked values (containing '…' or '****') are ignored — they come from the # admin UI's pre-filled form which displays masked secrets. # --------------------------------------------------------------------------- # @router.post("/test/llm") async def test_llm( api_url: str | None = None, api_key: str | None = None, model: str | None = None, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: settings = await get_all_settings(db) api_url = _resolve(api_url, settings.get("llm.api_url", "")) api_key = _resolve(api_key, settings.get("llm.api_key", "")) model = _resolve(model, settings.get("llm.model", "")) if not api_url: return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, "elapsed_ms": 0} client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1) start = time.monotonic() try: resp = await client.complete( stage="test_llm", messages=[{"role": "user", "content": "Reply with exactly: OK"}], temperature=0.0, max_tokens=10, session=db, ) elapsed = int((time.monotonic() - start) * 1000) return { "ok": True, "response": resp["message"].get("content", "").strip(), "model": model, "elapsed_ms": elapsed, "prompt_tokens": resp.get("prompt_tokens"), "completion_tokens": resp.get("completion_tokens"), } except Exception as e: # noqa: BLE001 elapsed = int((time.monotonic() - start) * 1000) return {"ok": False, "error": {"code": "connection_failed", "message": str(e)}, "elapsed_ms": elapsed} @router.post("/test/llm-tools") async def test_llm_tools( api_url: str | None = None, api_key: str | None = None, model: str | None = None, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: settings = await get_all_settings(db) api_url = _resolve(api_url, settings.get("llm.api_url", "")) api_key = _resolve(api_key, settings.get("llm.api_key", "")) model = _resolve(model, settings.get("llm.model", "")) if not api_url: return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"}, "elapsed_ms": 0, "has_tool_calls": False} client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=30.0, max_retries=1) start = time.monotonic() try: tools = [{ "type": "function", "function": { "name": "calc", "description": "Evaluate a math expression. You MUST call this tool.", "parameters": { "type": "object", "required": ["expression"], "properties": {"expression": {"type": "string", "description": "e.g. '2+2'"}}, }, }, }] resp = await client.complete( stage="test_llm_tools", messages=[ {"role": "system", "content": "You must use the calc tool to answer math questions. Do not compute in your head."}, {"role": "user", "content": "What is 2+2? You MUST call the calc tool with expression '2+2'."}, ], tools=tools, tool_choice="auto", temperature=0.0, max_tokens=100, session=db, ) elapsed = int((time.monotonic() - start) * 1000) tcs = resp["message"].get("tool_calls") or [] # Also try to parse tool calls from content (some models emit them as text) if not tcs: content = resp["message"].get("content", "") or "" parsed_tcs = _parse_text_tool_calls(content) if parsed_tcs: tcs = parsed_tcs return { "ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed, "raw_response": resp["message"], } except Exception as e: # noqa: BLE001 elapsed = int((time.monotonic() - start) * 1000) return {"ok": False, "error": {"code": "connection_failed", "message": str(e)}, "elapsed_ms": elapsed, "has_tool_calls": False} # Pattern: call:tool_name{args} or name{args} or name(args) import re as _re _TOOL_CALL_PATTERNS = [ # call:name{json_args} _re.compile(r"call:(\w+)\s*\{([^}]*)\}"), # name{args} _re.compile(r"\s*(\w+)\s*\{([^}]*)\}\s*"), # name({"key": "value", ...}) _re.compile(r"(\w+)\s*\(\s*(\{[^}]*\})\s*\)"), ] def _parse_text_tool_calls(content: str) -> list[dict]: """Parse tool calls emitted as text (some models don't use the OpenAI format). Handles patterns like: - call:calc{"expression": "2+2"} - calc{"expression": "2+2"} - calc({"expression": "2+2"}) """ import json as _json calls: list[dict] = [] for pattern in _TOOL_CALL_PATTERNS: for match in pattern.finditer(content): name = match.group(1) args_str = match.group(2).strip() try: args = _json.loads(args_str) except _json.JSONDecodeError: # Try to fix common issues (single quotes, missing quotes on keys) try: fixed = args_str.replace("'", '"') args = _json.loads(fixed) except _json.JSONDecodeError: args = {"_raw": args_str} calls.append({ "id": f"parsed_{len(calls)}", "type": "function", "function": {"name": name, "arguments": _json.dumps(args)}, }) return calls @router.post("/test/embeddings") async def test_embeddings( api_url: str | None = None, api_key: str | None = None, model: str | None = None, provider: str | None = None, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: settings = await get_all_settings(db) provider = provider or settings.get("embeddings.provider", "offline_hash") start = time.monotonic() try: if provider == "offline_hash": emb = build_hash_embedder(int(settings.get("embeddings.dimension", 256))) vecs = await emb.embed(["hello world"]) elapsed = int((time.monotonic() - start) * 1000) return { "ok": True, "dimension": emb.dimension, "model": "offline_hash", "first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed, } api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", "")) api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", "")) model = _resolve(model, settings.get("embeddings.model", "")) if not api_url: return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"}, "elapsed_ms": 0} emb = build_openai_embedder( api_url=api_url, api_key=api_key, model=model, dimension=int(settings.get("embeddings.dimension", 1536)), timeout=15.0, ) vecs = await emb.embed(["hello world"]) elapsed = int((time.monotonic() - start) * 1000) return { "ok": True, "dimension": len(vecs[0]) if vecs else 0, "model": model, "first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed, } except Exception as e: # noqa: BLE001 elapsed = int((time.monotonic() - start) * 1000) return {"ok": False, "error": {"code": "connection_failed", "message": str(e)}, "elapsed_ms": elapsed} @router.post("/test/embeddings/probe-dimension") async def probe_dimension( api_url: str | None = None, api_key: str | None = None, model: str | None = None, provider: str | None = None, db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: settings = await get_all_settings(db) provider = provider or settings.get("embeddings.provider", "offline_hash") start = time.monotonic() try: if provider == "offline_hash": return { "ok": True, "dimension": int(settings.get("embeddings.dimension", 256)), "elapsed_ms": 0, } api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", "")) api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", "")) model = _resolve(model, settings.get("embeddings.model", "")) emb = build_openai_embedder( api_url=api_url, api_key=api_key, model=model, dimension=int(settings.get("embeddings.dimension", 1536)), timeout=15.0, ) dim = await emb.probe_dimension() elapsed = int((time.monotonic() - start) * 1000) return {"ok": True, "dimension": dim, "elapsed_ms": elapsed} except Exception as e: # noqa: BLE001 elapsed = int((time.monotonic() - start) * 1000) return {"ok": False, "error": {"code": "probe_failed", "message": str(e)}, "elapsed_ms": elapsed} @router.post("/embeddings/recreate-collections") async def recreate_collections( db: AsyncSession = Depends(get_db), _user: User = Depends(require_admin), ) -> dict: """Drop and recreate Qdrant collections with the current embedding dimension.""" from app.core.qdrant_client import get_qdrant_client settings = await get_all_settings(db) cfg = get_settings() prefix = cfg.qdrant_collection_prefix or "" client = get_qdrant_client() existing = {c.name for c in (await client.get_collections()).collections} dropped = [] for name in (f"{prefix}entities", f"{prefix}story_entries"): if name in existing: await client.delete_collection(name) dropped.append(name) result = await init_qdrant_collections(int(settings.get("embeddings.dimension", 256))) return {"dropped": dropped, "created": result["created"], "dimension": result["dimension"]} # --------------------------------------------------------------------------- # # Icon upload # --------------------------------------------------------------------------- # @router.post("/upload-icon") async def upload_icon( file: UploadFile = File(...), kind: str = Form("favicon"), _user: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ) -> dict: cfg = get_settings() if kind not in ("favicon", "logo", "og_image"): raise HTTPException(400, "kind must be one of: favicon, logo, og_image") contents = await file.read() if len(contents) > cfg.max_upload_size_bytes: raise HTTPException(413, "file too large (max 1MB)") # Validate extension allowed_exts = {".png", ".svg", ".jpg", ".jpeg", ".webp", ".ico"} ext = Path(file.filename or "").suffix.lower() if ext not in allowed_exts: raise HTTPException(400, f"unsupported extension: {ext}") assets_dir = Path(cfg.assets_dir) assets_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") fname = f"{kind}_{ts}{ext}" out_path = assets_dir / fname out_path.write_bytes(contents) url = f"/static/assets/{fname}" setting_key = { "favicon": "ui.favicon_url", "logo": "ui.logo_url", "og_image": "ui.og_image_url", }[kind] await set_setting(db, setting_key, url) return {"ok": True, "kind": kind, "url": url, "size_bytes": len(contents)}