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
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue