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:
parent
94740daa77
commit
a87dc75a46
5 changed files with 233 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
96
app/model_menu.py
Normal 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,
|
||||
}
|
||||
34
app/store.py
34
app/store.py
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue