diff --git a/app/api/admin.py b/app/api/admin.py index b52d112..b8c0fc1 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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. diff --git a/app/core/costs.py b/app/core/costs.py index 0d0ade0..6145112 100644 --- a/app/core/costs.py +++ b/app/core/costs.py @@ -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): diff --git a/app/model_menu.py b/app/model_menu.py new file mode 100644 index 0000000..ac592c2 --- /dev/null +++ b/app/model_menu.py @@ -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, + } diff --git a/app/store.py b/app/store.py index 6a1dcd6..3b0f456 100644 --- a/app/store.py +++ b/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.") diff --git a/app/web/app.js b/app/web/app.js index 036133f..9886d2f 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1785,6 +1785,17 @@ async function loadStatus() { ${modelCard} + +
✅ im Eval bestätigt · 🔧 Tools laut Anbieter · Preise pro Mio. Token (⬆ Eingabe / ⬇ Ausgabe) · ~$/Antwort geschätzt.
+ + +Fehler beim Laden.
'; @@ -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 = 'lade …
'; + const data = await adminFetch("/api/admin/model-menu"); + if (!data) { box.innerHTML = 'Fehler beim Laden.
'; return; } + if (!data.available) { + box.innerHTML = `Katalog nicht verfügbar: ${escHtml(data.error || "unbekannt")}
`; + 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 ` +${escHtml(box.dataset.note || "")} ${list.length} Modelle.
+keine Treffer
'}