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>
195 lines
7.9 KiB
Python
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
|