"""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, }