Capture pipeline stdout into job.log + API/UI so failed runs can be reviewed, and let users pick OpenRouter vision vs text models independently. Co-authored-by: Cursor <cursoragent@cursor.com>
102 lines
3.4 KiB
Python
102 lines
3.4 KiB
Python
"""
|
|
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
|