"""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"]) # --------------------------------------------------------------------------- # # 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() return { "items": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows], "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, } # --------------------------------------------------------------------------- # # Test endpoints — LLM, embeddings, embeddings probe dimension # --------------------------------------------------------------------------- # @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 = api_url or settings.get("llm.api_url", "") api_key = api_key or settings.get("llm.api_key", "") model = model or 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 = api_url or settings.get("llm.api_url", "") api_key = api_key or settings.get("llm.api_key", "") model = model or 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=15.0, max_retries=1) start = time.monotonic() try: tools = [{ "type": "function", "function": { "name": "calc", "description": "Evaluate a math expression", "parameters": { "type": "object", "required": ["expression"], "properties": {"expression": {"type": "string"}}, }, }, }] resp = await client.complete( stage="test_llm_tools", messages=[{"role": "user", "content": "What is 2+2? Use the calc tool."}], tools=tools, temperature=0.0, max_tokens=100, session=db, ) elapsed = int((time.monotonic() - start) * 1000) tcs = resp["message"].get("tool_calls") or [] return { "ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "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, "has_tool_calls": False} @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 = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "") api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "") model = model or 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 = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "") api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "") model = model or 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)}