This commit is contained in:
Mikan
2026-06-21 06:12:28 +03:00
parent 4dee4fb0a8
commit e98559a587
29 changed files with 1248 additions and 267 deletions

View File

@@ -40,6 +40,76 @@ _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
# --------------------------------------------------------------------------- #
@@ -95,8 +165,13 @@ async def list_llm_logs(
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": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows],
"items": items,
"total": total, "page": page, "per_page": per_page,
}
@@ -217,6 +292,55 @@ async def stats(
}
# --------------------------------------------------------------------------- #
# 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
# --------------------------------------------------------------------------- #