96 lines
3.3 KiB
Python
96 lines
3.3 KiB
Python
|
|
"""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,
|
||
|
|
}
|