This commit is contained in:
Mikan
2026-06-20 19:13:05 +03:00
parent 32575e217e
commit 8514c63ec6
193 changed files with 22105 additions and 11660 deletions

401
app/api/admin.py Normal file
View File

@@ -0,0 +1,401 @@
"""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)}