Files
ai-agent/backend/openrouter.py
Gabriel Vidal 14aca8ea09 feat(models): browse and price every OpenRouter model from APPE's catalogue
The pi.dev harness could only be pointed at two hand-written qwen entries, and a
pi session was costed at one flat qwen rate whatever it actually ran on.

- `backend/openrouter.py` pulls APPE's published catalogue
  (appe.dev.gabvdl.xyz/api/models/openrouter.json — a daily models.dev sync) and
  serves it at `GET /api/models/openrouter`: ~340 models with $/Mtok in / out /
  cache-read, context window, parameter size and capability tags. Cached 6h
  in-process, with a `/data` disk copy so a restart with no network still lists
  models, and warmed at startup so nothing waits on it.
- `conversations.rates_for()` prices a turn from that catalogue — per model,
  cache-read included (OpenRouter's cache price is per-model policy, not
  Anthropic's flat 10%). Unknown ids keep the old estimate; pi's own `costUSD`
  still wins over any of it.
- The composer's model select gains `Browse OpenRouter…` on the pi harness: a
  FuzzyList of the whole catalogue, each row stating the three rates that bill an
  agent run plus size and context, with capability filters. Cheapest first.
- Any vendor-prefixed id now resolves to the pi harness frontend-side
  (`isOpenRouterId`), so an arbitrary pick survives a resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:48:52 +02:00

195 lines
7.9 KiB
Python

"""
The OpenRouter catalogue — every model a pi.dev session can be spawned on.
The composer's model select used to offer two hand-written pi entries (one
OpenRouter qwen, one local EVOX2 qwen). OpenRouter serves several hundred, and
picking between them is a *price* decision, so the picker now lists the lot with
what each one costs.
**The prices are not ours.** They come from APPE
(https://appe.dev.gabvdl.xyz), the homelab's model-data app, which syncs
models.dev daily and publishes its catalogue as static JSON
(`/api/models/openrouter.json`, one entry per model with $/Mtok in / out /
cache-read, context window, mined parameter count, capability tags and a
measured tokens/sec). Keeping a second price table here is exactly the bug this
avoids: when OpenRouter re-prices a model, APPE's next daily sync carries it and
this module picks it up within the TTL.
Three layers so the picker is never empty:
1. in-process cache (``CACHE_TTL_S``);
2. a disk copy under ``/data`` — written on every successful fetch, read when
APPE is unreachable, so a restart with no network still lists models;
3. nothing — the picker falls back to the curated ``PI_MODELS_DEFAULT`` list
in ``models.py`` and the flat qwen price estimate in ``conversations.py``.
The catalogue is also what **prices** a pi session: ``rates()`` hands
``conversations.py`` the per-model $/Mtok, so an OpenRouter run is costed at its
real rates instead of a one-size-fits-all qwen guess. (A pi transcript that
carries pi's own ``costUSD`` still wins over any estimate — see
``conversations.feed``.)
"""
import json
import os
import pathlib
import threading
import time
import urllib.error
import urllib.request
# APPE's published slice for the openrouter provider. Point at a file:// URL or
# another host with APPE_OPENROUTER_URL if APPE moves.
CATALOGUE_URL = os.environ.get(
"APPE_OPENROUTER_URL",
"https://appe.dev.gabvdl.xyz/api/models/openrouter.json")
CACHE_TTL_S = 6 * 3600
# Survives a restart when APPE is unreachable (same writable volume as the
# conversation metadata).
CACHE_PATH = pathlib.Path(
os.environ.get("OPENROUTER_CACHE_PATH", "/data/openrouter-models.json"))
FETCH_TIMEOUT_S = 15
_lock = threading.Lock()
_cache: tuple[float, list[dict]] | None = None
_index: dict[str, dict] = {} # model id → entry, for the pricing lookups
_generated_at: str = ""
def _shape(entry: dict) -> dict | None:
"""One APPE catalogue row → one pickable model.
``api_id`` is the id **without** the ``openrouter/`` prefix — exactly the
``--model`` argument the pi runner passes to OpenRouter. Everything else is
renamed to the viewer's camelCase and left otherwise untouched: prices stay
USD per million tokens, ``modelSize`` is billions of parameters (null for
models that publish none), ``speedTps`` is median output tokens/sec.
"""
mid = entry.get("api_id") or ""
if not mid:
return None
return {
"id": mid,
"name": entry.get("name") or mid,
"vendor": mid.split("/")[0],
"description": entry.get("description") or "",
"inputCost": float(entry.get("input_cost") or 0.0),
"outputCost": float(entry.get("output_cost") or 0.0),
# None (not 0) when the model has no prompt cache: a missing cache price
# and a free cache are different facts and the picker shows them so.
"cacheCost": (float(entry["cache_cost"])
if entry.get("cache_cost") is not None else None),
"contextWindow": entry.get("max_token") or None,
"modelSize": entry.get("model_size") or None,
"tags": entry.get("tags") or [],
"tier": entry.get("tier") or "",
"license": entry.get("license") or "",
"speedTps": entry.get("speed_tps") or None,
}
def _parse(payload: dict) -> tuple[list[dict], str]:
rows = payload.get("models") or []
models = [m for m in (_shape(r) for r in rows) if m]
# Cheapest first (blended 3:1 in:out, the usual agent mix), so the browser's
# unfiltered list opens on what a run costs least to try.
models.sort(key=lambda m: (m["inputCost"] * 0.75 + m["outputCost"] * 0.25,
m["id"]))
return models, str(payload.get("generatedAt") or "")
def _from_disk() -> tuple[list[dict], str]:
try:
return _parse(json.loads(CACHE_PATH.read_text(encoding="utf-8")))
except (OSError, ValueError):
return [], ""
def _fetch() -> tuple[list[dict], str]:
req = urllib.request.Request(
CATALOGUE_URL, headers={"accept": "application/json",
"user-agent": "ai-agent/openrouter-catalogue"})
try:
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_S) as r:
raw = r.read().decode()
payload = json.loads(raw)
models, generated = _parse(payload)
if models:
try:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
CACHE_PATH.write_text(raw, encoding="utf-8")
except OSError:
pass # read-only volume — the in-process cache still serves
return models, generated
except (urllib.error.URLError, OSError, ValueError):
pass
return _from_disk()
def _store(models: list[dict], generated: str, stamp: float | None = None) -> None:
"""Publish a catalogue. `stamp=0` marks it as not-yet-refreshed (the disk
copy), so the next `catalogue()` still goes and asks APPE."""
global _cache, _generated_at, _index
with _lock:
# A failed refresh keeps whatever we had rather than emptying the picker.
if models or not _cache:
_cache = (time.time() if stamp is None else stamp, models)
_generated_at = generated
_index = {m["id"]: m for m in models}
def catalogue(force: bool = False) -> list[dict]:
"""Every OpenRouter model, cheapest first. Empty only if APPE was never reached.
May block on the network (once per TTL). `warm()` calls it off the request
path at startup; the pricing lookups never do.
"""
with _lock:
if not force and _cache and time.time() - _cache[0] < CACHE_TTL_S:
return _cache[1]
_store(*_fetch()) # outside the lock: a slow call blocks nobody
return (_cache or (0.0, []))[1]
def warm() -> None:
"""Refresh the catalogue in the background (called at startup)."""
threading.Thread(target=catalogue, kwargs={"force": True},
name="openrouter-catalogue", daemon=True).start()
def generated_at() -> str:
"""When APPE generated the catalogue we are serving (ISO 8601, "" if unknown)."""
catalogue()
return _generated_at
def find(model_id: str) -> dict | None:
"""The catalogue entry for a `--model` argument, or None if it isn't OpenRouter's.
**Never fetches.** Transcript parsing prices every turn through here, and a
parse must not stall on an HTTP call; it reads the in-process index, falling
back to the disk copy once. A cold, network-less start therefore prices at
the flat estimate rather than hanging.
"""
mid = (model_id or "").strip()
if not mid or "/" not in mid:
return None # bare ids are the local EVOX2 provider, not OpenRouter
if _cache is None:
models, generated = _from_disk()
_store(models, generated, stamp=0.0)
return _index.get(mid)
def rates(model_id: str) -> tuple[float, float, float] | None:
"""(input, output, cache-read) $ per million tokens for an OpenRouter model.
None when the id isn't in the catalogue, which is the caller's cue to fall
back to its own estimate. A model with no prompt cache reports its cache-read
rate as the input rate — a cache read it can't serve is billed as a fresh
read, which is what the tokens would actually cost.
"""
m = find(model_id)
if not m:
return None
cache = m["cacheCost"] if m["cacheCost"] is not None else m["inputCost"]
return m["inputCost"], m["outputCost"], cache