""" models_catalog.py - OpenRouter model list for the UI dropdowns. Fetches https://openrouter.ai/api/v1/models (cached ~1h) and splits into: - vision: accepts image input and returns text - text: chat models that return text (may also be multimodal) """ import time from typing import Any, Dict, List from backend import config _TTL_SEC = 3600 _cache: Dict[str, Any] = {"at": 0.0, "payload": None} def _entry(m: Dict[str, Any]) -> Dict[str, str]: return { "id": m.get("id") or "", "name": m.get("name") or m.get("id") or "", } def _ensure_default(items: List[Dict[str, str]], model_id: str) -> List[Dict[str, str]]: """Prepend the configured default if OpenRouter didn't return it.""" if not model_id: return items if any(x["id"] == model_id for x in items): return items return [{"id": model_id, "name": model_id}] + items def _fetch_raw() -> List[Dict[str, Any]]: import httpx # local import so the app can start without httpx in odd envs headers = {"Accept": "application/json"} if config.AI_API_KEY: headers["Authorization"] = f"Bearer {config.AI_API_KEY}" url = f"{config.AI_BASE_URL.rstrip('/')}/models" # Ask for text-output chat models (includes multimodal). "all" is huge. with httpx.Client(timeout=30.0) as client: r = client.get(url, headers=headers, params={"output_modalities": "text"}) r.raise_for_status() data = r.json() return data.get("data") or [] def list_models() -> Dict[str, Any]: """Return {vision, text, defaults} for the frontend selects.""" now = time.time() if _cache["payload"] and (now - _cache["at"]) < _TTL_SEC: return _cache["payload"] try: raw = _fetch_raw() except Exception as e: # Degrade to configured defaults so the UI still works offline. print(f"[Models] OpenRouter catalog fetch failed: {e}") vision = _ensure_default([], config.MODEL) text = _ensure_default([], config.TEXT_MODEL) payload = { "vision": vision, "text": text, "defaults": {"vision": config.MODEL, "text": config.TEXT_MODEL}, "error": str(e), } return payload vision: List[Dict[str, str]] = [] text: List[Dict[str, str]] = [] for m in raw: mid = m.get("id") or "" if not mid: continue arch = m.get("architecture") or {} inputs = arch.get("input_modalities") or [] outputs = arch.get("output_modalities") or [] # Legacy string form: "text+image->text" modality = (arch.get("modality") or "").lower() has_image_in = ("image" in inputs) or ("image" in modality.split("->")[0]) has_text_out = ("text" in outputs) or ("->text" in modality) or (not outputs and not modality) has_text_in = ("text" in inputs) or ("text" in modality) or not inputs if has_image_in and has_text_out: vision.append(_entry(m)) if has_text_in and has_text_out: text.append(_entry(m)) vision.sort(key=lambda x: x["name"].lower()) text.sort(key=lambda x: x["name"].lower()) vision = _ensure_default(vision, config.MODEL) text = _ensure_default(text, config.TEXT_MODEL) payload = { "vision": vision, "text": text, "defaults": {"vision": config.MODEL, "text": config.TEXT_MODEL}, } _cache["at"] = now _cache["payload"] = payload return payload