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

@ -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.")