402 lines
15 KiB
Python
402 lines
15 KiB
Python
|
|
"""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}
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# 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()
|
||
|
|
worlds_count = (await db.execute(select(func.count(World.id)))).scalar_one()
|
||
|
|
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": worlds_count,
|
||
|
|
"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)}
|