my_voice_assistant_v3_jamulix/app/model_menu.py
dschlueter f1eec47b9b chore: Version 0.3.6 + CHANGELOG (Modell-Browser) + qwen3.5-35b-a3b eval-verifiziert
CHANGELOG-Eintrag fuer den Cloud-Modell-Browser (0.3.6); qwen/qwen3.5-35b-a3b
in EVAL_VERIFIED (Tool-Calling-Test bestanden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 02:49:28 +02:00

97 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",
"qwen/qwen3.5-35b-a3b",
}
# 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,
}