""" models.py - Fetch the available OpenRouter model list with pricing (cached). The /models endpoint is public (no API key needed). Results are normalized to per-1M-token USD costs for display and cached in memory for an hour; callers degrade gracefully when OpenRouter is unreachable. Each entry also carries a vision flag (accepts image input) so the UI can offer separate vision/text model dropdowns. """ import time from typing import List, Optional, Tuple import httpx from backend import config _CACHE_TTL_SECONDS = 3600 _cache = {"at": 0.0, "models": None} def _per_mtok(rate) -> float: """OpenRouter pricing is USD per token (as a string); display is per 1M.""" try: return round(float(rate) * 1_000_000, 4) except (TypeError, ValueError): return 0.0 def _is_vision(item: dict) -> bool: """True when the model accepts image input and produces text output.""" arch = item.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) return has_image_in and has_text_out def _fetch_openrouter_models() -> Optional[List[dict]]: """Raw GET of the OpenRouter model list; None on any failure.""" try: response = httpx.get(f"{config.AI_BASE_URL.rstrip('/')}/models", timeout=10) response.raise_for_status() data = response.json().get("data") return data if isinstance(data, list) else None except Exception as e: print(f"[Models] OpenRouter /models fetch failed: {e}") return None def fetch_models(force: bool = False) -> Optional[List[dict]]: """Normalized model list for the UI picker, or None when unavailable.""" if ( not force and _cache["models"] is not None and time.time() - _cache["at"] < _CACHE_TTL_SECONDS ): return _cache["models"] data = _fetch_openrouter_models() if data is None: return None models = [ { "id": item.get("id") or "", "name": item.get("name") or item.get("id") or "", "prompt_usd_per_mtok": _per_mtok((item.get("pricing") or {}).get("prompt")), "completion_usd_per_mtok": _per_mtok((item.get("pricing") or {}).get("completion")), "context_length": item.get("context_length"), "vision": _is_vision(item), } for item in data if item.get("id") ] models.sort(key=lambda m: m["id"]) _cache["models"] = models _cache["at"] = time.time() return models def split_vision_text(models: List[dict]) -> Tuple[List[dict], List[dict]]: """Partition the normalized catalog into (vision, text) lists for the UI. Every catalog model takes text in/out, so vision models appear in both lists (same dicts, pricing included). """ vision = [m for m in models if m.get("vision")] return vision, list(models)