- Job logs: each job's stdout/stderr is teed into outputs/<id>/job.log
(survives restarts) and served at GET /jobs/{id}/log as text/plain, so
full run logs can be shared for debugging and refinement.
- Model picker: GET /models proxies OpenRouter's public model list with
per-1M-token pricing (1h cache, 502 on failure); the UI shows a model
dropdown with costs when OpenRouter compute is selected, and the pick
overrides vision+text models for that job (Classic and Agent modes).
- Conflicts in the report view are grouped by discipline pair
(collapsible sections, severity-ordered within groups) instead of one
flat severity-only list.
66 lines
2.0 KiB
Python
66 lines
2.0 KiB
Python
"""
|
|
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.
|
|
"""
|
|
|
|
import time
|
|
from typing import List, Optional
|
|
|
|
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 _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"),
|
|
}
|
|
for item in data
|
|
if item.get("id")
|
|
]
|
|
models.sort(key=lambda m: m["id"])
|
|
_cache["models"] = models
|
|
_cache["at"] = time.time()
|
|
return models
|