"""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,
}
# --------------------------------------------------------------------------- #
# Name bank — get/update character name banks per language
# --------------------------------------------------------------------------- #
@router.get("/names/{language}")
async def get_name_bank(
language: str,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
"""Get the character name bank for a language."""
names = await get_setting(db, f"character_names.{language}")
if not isinstance(names, list):
names = []
return {"language": language, "names": names, "count": len(names)}
@router.put("/names/{language}")
async def update_name_bank(
language: str,
body: dict,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
"""Update the character name bank for a language.
Body: {names: ["name1", "name2", ...]}
"""
names = body.get("names", [])
if not isinstance(names, list):
raise HTTPException(400, "names must be an array")
# Validate all entries are strings
cleaned = [str(n).strip() for n in names if str(n).strip()]
await set_setting(db, f"character_names.{language}", cleaned)
return {"language": language, "names": cleaned, "count": len(cleaned)}
@router.post("/names/{language}/add")
async def add_name_to_bank(
language: str,
body: dict,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
"""Add a single name to the bank. Body: {name: "..."}"""
name = body.get("name", "").strip()
if not name:
raise HTTPException(400, "name is required")
names = await get_setting(db, f"character_names.{language}")
if not isinstance(names, list):
names = []
if name not in names:
names.append(name)
await set_setting(db, f"character_names.{language}", names)
return {"language": language, "names": names, "count": len(names)}
@router.delete("/names/{language}/{name}")
async def remove_name_from_bank(
language: str,
name: str,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
"""Remove a name from the bank."""
names = await get_setting(db, f"character_names.{language}")
if not isinstance(names, list):
names = []
names = [n for n in names if n != name]
await set_setting(db, f"character_names.{language}", names)
return {"language": language, "names": names, "count": len(names)}
# --------------------------------------------------------------------------- #
# 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)}