feat(admin): Modell-Browser (Cloud) mit Tool-Badge, Preisen & $/Antwort

Phase 1 (Cloud) des Modell-Auswahlmenues:
- Token je Turn im CostMeter (record_openrouter_cost liest prompt/completion_
  tokens) -> globales Turn-Profil (turn_profile, EMA) fuer die $/Antwort-Schaetzung.
- app/model_menu.py: gecachter OpenRouter-Katalog -> Chat-Modelle mit tool_supported
  (supported_parameters), eval_verified (kuratiert), Up/Down-Preisen pro Mio. Token
  und geschaetzter $/Antwort (Profil x Modellpreise).
- GET /api/admin/model-menu; durchsuchbarer Browser im System-Tab, "Verwenden"
  setzt openrouter_llm_model (Freitext/Preset bleiben).
Lokale Modelle (Durchsatz/Strom): Phase 2. Konzept in Ideen/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 22:37:36 +02:00
commit a87dc75a46
5 changed files with 233 additions and 3 deletions

View file

@ -175,6 +175,14 @@ async def wipe_all_user_data(request: Request):
return counts
@router.get("/admin/model-menu", dependencies=[Depends(require_admin)])
async def model_menu():
"""Benutzbare Cloud-Modelle (OpenRouter): Tool-Fähigkeit, ⬆/⬇-Preise pro
Mio. Token und geschätzte $/Antwort. Lokale Modelle folgen in Phase 2."""
from app.model_menu import build_model_menu
return await build_model_menu(get_store())
@router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)])
async def reset_token(user_id: str):
"""Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig.

View file

@ -16,11 +16,13 @@ _meter: contextvars.ContextVar = contextvars.ContextVar("cost_meter", default=No
class CostMeter:
__slots__ = ("total", "by_cat")
__slots__ = ("total", "by_cat", "prompt_tokens", "completion_tokens")
def __init__(self):
self.total: float = 0.0
self.by_cat: dict[str, float] = {}
self.prompt_tokens: int = 0 # Summe über alle (Cloud-)Sub-Calls des Turns
self.completion_tokens: int = 0
def add(self, usd, category: str) -> None:
try:
@ -32,6 +34,13 @@ class CostMeter:
self.total += usd
self.by_cat[category] = self.by_cat.get(category, 0.0) + usd
def add_tokens(self, prompt, completion) -> None:
try:
self.prompt_tokens += int(prompt or 0)
self.completion_tokens += int(completion or 0)
except (TypeError, ValueError):
pass
def start_meter() -> CostMeter:
"""Setzt einen frischen Zähler für den aktuellen (Request-)Kontext."""
@ -52,18 +61,25 @@ def add_cost(usd, category: str) -> None:
def record_openrouter_cost(usage, category: str) -> None:
"""Liest `cost` aus einem OpenRouter-`usage`-Objekt und bucht es (USD)."""
"""Liest `cost` + Token aus einem OpenRouter-`usage`-Objekt und bucht sie."""
if isinstance(usage, dict):
add_cost(usage.get("cost"), category)
meter = _meter.get()
if meter is not None:
meter.add_tokens(usage.get("prompt_tokens"), usage.get("completion_tokens"))
def persist_costs(user, store) -> None:
"""Schreibt die im aktuellen Meter gesammelten Kosten pro Kategorie in den Store."""
"""Schreibt die Kosten pro Kategorie in den Store und aktualisiert das
globale Turn-Token-Profil (für die $/Antwort"-Schätzung im Modell-Menü)."""
meter = _meter.get()
if meter is None or user is None:
return
for category, amount in meter.by_cat.items():
store.add_cost_usage(user.id, category, amount)
# Nur Turns mit echter Cloud-Token-Messung ins Profil aufnehmen.
if meter.completion_tokens > 0:
store.update_turn_profile(meter.prompt_tokens, meter.completion_tokens)
def _cfg(cfg):

96
app/model_menu.py Normal file
View file

@ -0,0 +1,96 @@
"""Modell-Auswahlmenü (Phase 1: Cloud/OpenRouter).
Liefert die benutzbaren Chat-Modelle aus dem OpenRouter-Katalog mit Tool-Fähigkeit,
/-Preisen pro Mio. Token und einer geschätzten $/Antwort" (aus dem live
gemessenen Turn-Token-Profil). Katalog wird gecacht. Lokale Modelle: Phase 2.
Siehe Ideen/MODELL_MENUE_KONZEPT.md.
"""
import time
import httpx
from app.runtime_config import runtime_settings
_CATALOG_URL = "https://openrouter.ai/api/v1/models"
_TTL = 3600.0 # Katalog ändert sich selten
_cache: dict = {"t": 0.0, "data": None}
# Im eval/tool_calling als web-suchtauglich bestätigt (✅). Klein halten/pflegen.
EVAL_VERIFIED = {
"mistralai/mistral-small-3.2-24b-instruct",
}
# Annahmen, bis echte Turn-Messdaten vorliegen.
_DEFAULT_PROMPT_TOKENS = 700
_DEFAULT_COMPLETION_TOKENS = 130
def invalidate_cache() -> None:
_cache["data"] = None
def _f(x) -> float:
try:
return float(x)
except (TypeError, ValueError):
return 0.0
async def _fetch_catalog(api_key: str) -> list:
now = time.monotonic()
if _cache["data"] is not None and now - _cache["t"] < _TTL:
return _cache["data"]
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(_CATALOG_URL, headers=headers)
resp.raise_for_status()
data = resp.json().get("data", [])
_cache["data"] = data
_cache["t"] = now
return data
async def build_model_menu(store=None, cfg=None) -> dict:
cfg = cfg or runtime_settings
try:
catalog = await _fetch_catalog(cfg.openrouter_api_key)
except Exception as exc: # noqa: BLE001 — graceful, UI zeigt Fehler
return {"available": False, "error": str(exc), "models": [], "profile": {}}
prof = store.get_turn_profile() if store is not None else {}
avg_p = prof.get("avg_prompt") or _DEFAULT_PROMPT_TOKENS
avg_c = prof.get("avg_completion") or _DEFAULT_COMPLETION_TOKENS
models = []
for m in catalog:
modality = (m.get("architecture") or {}).get("modality") or ""
if not modality.endswith("->text"): # nur Chat-/Completion-Modelle
continue
pricing = m.get("pricing") or {}
p_in = _f(pricing.get("prompt")) # $/Token
p_out = _f(pricing.get("completion"))
mid = m.get("id")
models.append({
"id": mid,
"name": m.get("name") or mid,
"context_length": m.get("context_length"),
"price_in_per_mtok": round(p_in * 1_000_000, 4),
"price_out_per_mtok": round(p_out * 1_000_000, 4),
"tool_supported": "tools" in (m.get("supported_parameters") or []),
"eval_verified": mid in EVAL_VERIFIED,
"est_cost_per_answer": round(avg_p * p_in + avg_c * p_out, 6),
})
# Sortierung: eval-bestätigt zuerst, dann tool-fähig, dann alphabetisch.
models.sort(key=lambda x: (not x["eval_verified"], not x["tool_supported"], x["name"].lower()))
return {
"available": True,
"models": models,
"profile": {
"avg_prompt": round(avg_p),
"avg_completion": round(avg_c),
"measured": bool(prof.get("samples")),
},
"current": cfg.openrouter_llm_model,
}

View file

@ -136,6 +136,14 @@ class Store(ABC):
def cost_for_day(self, user_id: str, day: str | None = None) -> float:
"""Summe der Kosten (USD) des Nutzers am Tag (Default: heute, UTC)."""
@abstractmethod
def update_turn_profile(self, prompt_tokens: int, completion_tokens: int) -> None:
"""Aktualisiert das gleitende Mittel der Token je Turn (für „$/Antwort")."""
@abstractmethod
def get_turn_profile(self) -> dict:
"""Liefert {avg_prompt, avg_completion, samples} oder {} (noch ungemessen)."""
@abstractmethod
def delete_user(self, user_id: str) -> bool:
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
@ -272,6 +280,10 @@ class SQLiteStore(Store):
amount_usd REAL NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day, category)
);
CREATE TABLE IF NOT EXISTS turn_profile (
k TEXT PRIMARY KEY,
v REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS emergency_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@ -569,6 +581,28 @@ class SQLiteStore(Store):
).fetchone()
return float(row["s"]) if row else 0.0
def update_turn_profile(self, prompt_tokens: int, completion_tokens: int,
alpha: float = 0.1) -> None:
with self._connect() as conn:
cur = {r["k"]: r["v"] for r in conn.execute("SELECT k, v FROM turn_profile")}
n = cur.get("samples", 0)
if n <= 0:
ap, ac = float(prompt_tokens), float(completion_tokens)
else:
ap = (1 - alpha) * cur.get("avg_prompt", 0.0) + alpha * prompt_tokens
ac = (1 - alpha) * cur.get("avg_completion", 0.0) + alpha * completion_tokens
for k, val in (("avg_prompt", ap), ("avg_completion", ac), ("samples", n + 1)):
conn.execute(
"INSERT INTO turn_profile(k, v) VALUES(?, ?)"
" ON CONFLICT(k) DO UPDATE SET v = excluded.v",
(k, val),
)
def get_turn_profile(self) -> dict:
with self._connect() as conn:
rows = conn.execute("SELECT k, v FROM turn_profile").fetchall()
return {r["k"]: r["v"] for r in rows}
def delete_user(self, user_id: str) -> bool:
if user_id == ANONYMOUS_USER_ID:
raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.")

View file

@ -1785,6 +1785,17 @@ async function loadStatus() {
${modelCard}
<!-- Modell-Browser (Cloud/OpenRouter) -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<div class="flex items-center justify-between mb-1">
<h3 class="font-semibold text-sm">Modell-Browser (Cloud)</h3>
<button id="model-menu-load" class="text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1 hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">Modelle laden</button>
</div>
<p class="text-xs text-slate-400 mb-2"> im Eval bestätigt · 🔧 Tools laut Anbieter · Preise pro Mio. Token ( Eingabe / Ausgabe) · ~$/Antwort geschätzt.</p>
<input id="model-menu-search" type="text" placeholder="Filtern (Name/ID) …" class="hidden w-full mb-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div id="model-menu-results" class="text-xs"></div>
</div>
<!-- Provider -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Provider (Standard-Route)</h3>
@ -1817,6 +1828,7 @@ async function loadStatus() {
</div>
`;
wireModelPicker();
wireModelBrowser();
} catch (e) {
console.error("loadStatus error:", e);
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
@ -1839,6 +1851,70 @@ function wireModelPicker() {
});
}
// Modell-Browser (Cloud): Katalog laden, filtern, ein Modell als openrouter_llm_model setzen.
let _modelMenu = [];
let _modelCurrent = "";
function wireModelBrowser() {
const btn = $("#model-menu-load");
if (btn) btn.addEventListener("click", loadModelMenu);
}
async function loadModelMenu() {
const box = $("#model-menu-results");
const search = $("#model-menu-search");
if (!box) return;
box.innerHTML = '<p class="text-slate-400 py-2">lade …</p>';
const data = await adminFetch("/api/admin/model-menu");
if (!data) { box.innerHTML = '<p class="text-red-500 py-2">Fehler beim Laden.</p>'; return; }
if (!data.available) {
box.innerHTML = `<p class="text-red-500 py-2">Katalog nicht verfügbar: ${escHtml(data.error || "unbekannt")}</p>`;
return;
}
_modelMenu = data.models || [];
_modelCurrent = data.current || "";
const p = data.profile || {};
box.dataset.note = `~$/Antwort bei Ø ${p.avg_prompt ?? "?"} Eingabe- + ${p.avg_completion ?? "?"} Ausgabe-Token (${p.measured ? "gemessen" : "Default"}).`;
if (search) {
search.classList.remove("hidden");
search.value = "";
search.oninput = () => renderModelMenu(search.value.trim().toLowerCase());
}
renderModelMenu("");
}
function renderModelMenu(q) {
const box = $("#model-menu-results");
if (!box) return;
const list = q
? _modelMenu.filter((m) => (m.id + " " + m.name).toLowerCase().includes(q))
: _modelMenu;
const fmt = (n) => "$" + (Number(n) || 0);
const rows = list.slice(0, 400).map((m) => {
const badge = m.eval_verified ? "✅" : (m.tool_supported ? "🔧" : "·");
const cur = m.id === _modelCurrent;
return `
<div class="flex items-center gap-2 py-1 border-b border-slate-100 dark:border-slate-800 ${cur ? "bg-blue-50 dark:bg-blue-900/20" : ""}">
<span class="w-4 text-center" title="${m.eval_verified ? "im Eval bestätigt" : m.tool_supported ? "Tools laut Anbieter" : "kein Tool-Support"}">${badge}</span>
<span class="flex-1 truncate font-mono" title="${escHtml(m.id)}">${escHtml(m.name)}</span>
<span class="w-16 text-right text-slate-500" title="Eingabe / Mio. Token">${fmt(m.price_in_per_mtok)}</span>
<span class="w-16 text-right text-slate-500" title="Ausgabe / Mio. Token">${fmt(m.price_out_per_mtok)}</span>
<span class="w-20 text-right text-slate-600 dark:text-slate-300" title="geschätzte Kosten pro Antwort">~$${(Number(m.est_cost_per_answer) || 0).toFixed(5)}</span>
<button class="btn-use-model text-xs rounded border border-slate-300 dark:border-slate-600 px-2 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-700 ${cur ? "opacity-50" : ""}" data-id="${escHtml(m.id)}">${cur ? "aktiv" : "Verwenden"}</button>
</div>`;
}).join("");
box.innerHTML = `
<p class="text-slate-400 mb-1">${escHtml(box.dataset.note || "")} ${list.length} Modelle.</p>
<div class="max-h-80 overflow-y-auto">${rows || '<p class="text-slate-400 py-2">keine Treffer</p>'}</div>`;
box.querySelectorAll(".btn-use-model").forEach((b) =>
b.addEventListener("click", () => useModel(b.dataset.id)));
}
async function useModel(id) {
if (!confirm(`Cloud-LLM auf „${id}" setzen?\n(Greift nur, wenn der LLM-Provider „openrouter" ist.)`)) return;
const res = await adminFetch("/api/admin/config/openrouter_llm_model", "PUT", { value: id });
if (res) { _modelCurrent = id; loadStatus(); }
}
// ════════════════════════════════════════
// TAB: WARTUNG (Konfiguration)
// ════════════════════════════════════════