feat: add model management endpoints and admin interface for cache status

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 15:52:02 +02:00
parent 6ef8816736
commit df24a07cd8
4 changed files with 219 additions and 19 deletions
+49 -19
View File
@@ -1,10 +1,12 @@
"""Admin router: operational endpoints for application management.""" """Admin router: operational endpoints for application management."""
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from ..db import get_conn, get_write_lock from ..db import get_conn, get_write_lock
from ..dependencies import require_admin from ..dependencies import require_admin
from ..services import models as models_service
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -13,16 +15,15 @@ router = APIRouter(prefix="/admin", tags=["admin"])
async def get_stats(_: dict = Depends(require_admin)) -> dict: async def get_stats(_: dict = Depends(require_admin)) -> dict:
"""Return aggregate statistics: user counts and token counts.""" """Return aggregate statistics: user counts and token counts."""
conn = get_conn() conn = get_conn()
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] sql_user_count = "SELECT COUNT(*) FROM users"
users_by_role = conn.execute( sql_user_counts = "SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role"
"SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role" sql_token_count = "SELECT COUNT(*) FROM refresh_tokens"
).fetchall() sql_tokens_active = "SELECT COUNT(*) FROM refresh_tokens WHERE revoked = false AND expires_at > ?"
total_tokens = conn.execute( now = datetime.now(timezone.utc)
"SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] total_users = conn.execute(sql_user_count).fetchone()[0]
active_tokens = conn.execute( users_by_role = conn.execute(sql_user_counts).fetchall()
"SELECT COUNT(*) FROM refresh_tokens WHERE revoked = false AND expires_at > ?", total_tokens = conn.execute(sql_token_count).fetchone()[0]
[datetime.now(timezone.utc)], active_tokens = conn.execute(sql_tokens_active, [now]).fetchone()[0]
).fetchone()[0]
return { return {
"users": { "users": {
"total": total_users, "total": total_users,
@@ -50,13 +51,42 @@ async def purge_tokens(_: dict = Depends(require_admin)) -> dict:
conn = get_conn() conn = get_conn()
lock = get_write_lock() lock = get_write_lock()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
sql_count = "SELECT COUNT(*) FROM refresh_tokens"
sql_delete = "DELETE FROM refresh_tokens WHERE revoked = true OR expires_at <= ?"
async with lock: async with lock:
before = conn.execute( before = conn.execute(sql_count).fetchone()[0]
"SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] conn.execute(sql_delete, [now])
conn.execute( after = conn.execute(sql_count).fetchone()[0]
"DELETE FROM refresh_tokens WHERE revoked = true OR expires_at <= ?", [ return {"deleted": before - after, "remaining": after}
now]
)
after = conn.execute( @router.get("/models/status")
"SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] async def get_model_status(_: dict = Depends(require_admin)) -> dict[str, Any]:
return {"deleted": before - after, "remaining": after} """Return model cache status: last update time and model count."""
conn = get_conn()
return models_service.get_cache_status(conn)
@router.get("/models")
async def get_all_models(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
"""Return all cached models."""
conn = get_conn()
return models_service.get_cached_models(conn)
@router.post("/models/refresh", status_code=200)
async def refresh_models(
_: dict = Depends(require_admin),
) -> dict[str, str | int | None]:
"""Force a refresh of the model cache from OpenRouter."""
conn = get_conn()
lock = get_write_lock()
async with lock:
count = await models_service.refresh_models_cache(conn)
status = models_service.get_cache_status(conn)
return {
"status": "ok",
"refreshed": count,
"total_models": status.get("model_count"),
"last_updated": status.get("last_updated"),
}
+9
View File
@@ -198,3 +198,12 @@ def get_model_output_modalities(
return json.loads(row[0]) return json.loads(row[0])
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return [] return []
def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
"""Return cache last update time and model count."""
row = conn.execute(
"SELECT MAX(fetched_at), COUNT(*) FROM models_cache"
).fetchone()
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
return {"last_updated": last_updated, "model_count": model_count}
+7
View File
@@ -390,6 +390,13 @@ def admin_delete_user(user_id: str):
return redirect(url_for("admin")) return redirect(url_for("admin"))
@app.get("/admin/models")
@admin_required
def admin_models():
"""Show model cache status and list all models."""
return render_template("admin/models.html")
# ── Profile ─────────────────────────────────────────────────────────────── # ── Profile ───────────────────────────────────────────────────────────────
@app.route("/users/profile", methods=["GET", "POST"]) @app.route("/users/profile", methods=["GET", "POST"])
+154
View File
@@ -0,0 +1,154 @@
{% extends "base.html" %} {% block title %}Admin - Model Management{% endblock
%} {% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">Admin: Model Management</h1>
<!-- Cache Status -->
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-semibold mb-2">Cache Status</h2>
<div id="cache-status" class="grid grid-cols-2 gap-4">
<p>
<strong>Last Updated:</strong> <span id="last-updated">Loading...</span>
</p>
<p>
<strong>Model Count:</strong> <span id="model-count">Loading...</span>
</p>
</div>
<button
id="refresh-button"
class="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Refresh Cache
</button>
<p id="refresh-status" class="mt-2 text-sm"></p>
</div>
<!-- Model List -->
<div class="bg-gray-800 p-4 rounded-lg shadow-md">
<h2 class="text-xl font-semibold mb-2">Available Models</h2>
<table id="models-table" class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-700">
<tr>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
ID
</th>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Modality
</th>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Context Length
</th>
</tr>
</thead>
<tbody
id="models-table-body"
class="bg-gray-800 divide-y divide-gray-700"
>
<!-- Data will be populated by JavaScript -->
<tr>
<td colspan="4" class="text-center py-4">Loading models...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const lastUpdatedEl = document.getElementById("last-updated");
const modelCountEl = document.getElementById("model-count");
const modelsTableBody = document.getElementById("models-table-body");
const refreshButton = document.getElementById("refresh-button");
const refreshStatus = document.getElementById("refresh-status");
async function fetchCacheStatus() {
try {
const response = await fetch("/api/v1/admin/models/status");
if (!response.ok) throw new Error("Failed to fetch status");
const data = await response.json();
lastUpdatedEl.textContent = data.last_updated
? new Date(data.last_updated).toLocaleString()
: "Never";
modelCountEl.textContent = data.model_count;
} catch (error) {
lastUpdatedEl.textContent = "Error";
modelCountEl.textContent = "Error";
console.error("Error fetching cache status:", error);
}
}
async function fetchModels() {
try {
const response = await fetch("/api/v1/admin/models");
if (!response.ok) throw new Error("Failed to fetch models");
const models = await response.json();
modelsTableBody.innerHTML = ""; // Clear loading message
if (models.length === 0) {
modelsTableBody.innerHTML =
'<tr><td colspan="4" class="text-center py-4">No models found in cache.</td></tr>';
} else {
models.forEach((model) => {
const row = `
<tr>
<td class="px-6 py-4 whitespace-nowrap">${model.name}</td>
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm">${model.id}</td>
<td class="px-6 py-4 whitespace-nowrap">${model.modality}</td>
<td class="px-6 py-4 whitespace-nowrap">${model.context_length || "N/A"}</td>
</tr>
`;
modelsTableBody.innerHTML += row;
});
}
} catch (error) {
modelsTableBody.innerHTML =
'<tr><td colspan="4" class="text-center py-4 text-red-500">Error loading models.</td></tr>';
console.error("Error fetching models:", error);
}
}
async function refreshCache() {
refreshButton.disabled = true;
refreshStatus.textContent = "Refreshing...";
refreshStatus.classList.remove("text-red-500", "text-green-500");
try {
const response = await fetch("/api/v1/admin/models/refresh", {
method: "POST",
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "Failed to refresh cache");
}
refreshStatus.textContent = `Successfully refreshed ${data.refreshed} models. Total: ${data.total_models}.`;
refreshStatus.classList.add("text-green-500");
fetchCacheStatus();
fetchModels();
} catch (error) {
refreshStatus.textContent = `Error: ${error.message}`;
refreshStatus.classList.add("text-red-500");
} finally {
refreshButton.disabled = false;
}
}
fetchCacheStatus();
fetchModels();
refreshButton.addEventListener("click", refreshCache);
});
</script>
{% endblock %}