feat(models): browse and price every OpenRouter model from APPE's catalogue #4
27
CLAUDE.md
27
CLAUDE.md
@@ -143,6 +143,16 @@ store / metadata sidecar.
|
||||
four chips) with the CLI alias attached. A newly released model appears in the
|
||||
composer with no code change. Falls back to a small static list when the key is
|
||||
missing or the call fails.
|
||||
- `openrouter.py` — the **OpenRouter catalogue** (`GET /api/models/openrouter`):
|
||||
every model a pi.dev session can run on, with its $/Mtok (input / output /
|
||||
cache-read), context window, parameter size and capability tags. The data is
|
||||
**APPE's** (`appe.dev.gabvdl.xyz/api/models/openrouter.json`, a daily
|
||||
models.dev sync), cached in-process for 6h with a disk copy under `/data` so a
|
||||
restart with no network still lists models. Deliberately separate from
|
||||
`/api/models`: that endpoint is the composer's short chip list, this is the few
|
||||
hundred rows the model browser searches. It also **prices** pi sessions —
|
||||
`conversations.rates_for()` reads a model's real rates from here instead of the
|
||||
flat qwen estimate, and only an id the catalogue doesn't know falls back.
|
||||
- `notify_audio.py` — the sound a notification made, replayable in the browser
|
||||
(`POST /api/notifications/audio` → a WAV, behind the ▶ button on an expanded
|
||||
notification card). There is **no synthesizer here**: it POSTs to the homelab
|
||||
@@ -531,6 +541,23 @@ glyphs render synchronously all over the app, so they can't come from a lazy
|
||||
chunk, and pulling ~1500 icons into the main bundle to serve one settings page
|
||||
is a bad trade.
|
||||
|
||||
### Picking a pi model: the OpenRouter browser
|
||||
|
||||
The model select lists the curated entries per harness, but on **pi** the real
|
||||
catalogue is a modal: `Browse OpenRouter…` opens `OpenRouterBrowser` — a
|
||||
`FuzzyList` over every OpenRouter model, each row stating the three rates that
|
||||
bill an agent run (**in / out / cache-read**, $ per million tokens), the model's
|
||||
parameter size and its context window, with capability chips (tools, reasoning,
|
||||
vision, open weights, free) to narrow the list. Cheapest first, so an unfiltered
|
||||
open lands on what costs least to try.
|
||||
|
||||
Picking one sends its full id (`openai/gpt-oss-20b`) as `--model`; the runner
|
||||
already routes **any** vendor-prefixed id to OpenRouter and bare ids to the local
|
||||
EVOX2 provider, so no allow-list has to be kept in step. The frontend reads the
|
||||
same shape: `isOpenRouterId()` (a slash — Claude ids never have one) is what
|
||||
makes an arbitrary pick resolve to the pi harness on resume, without the
|
||||
catalogue being loaded.
|
||||
|
||||
### Effort (claude) vs. thinking (pi)
|
||||
|
||||
How hard a run works a turn is **one knob shown two ways**, per harness — never
|
||||
|
||||
@@ -16,6 +16,8 @@ import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import openrouter
|
||||
|
||||
|
||||
# Claude Code session ids (= main transcript file stems) are UUIDs.
|
||||
_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
||||
@@ -438,12 +440,17 @@ def _worktree_events(text) -> list[dict]:
|
||||
|
||||
# Per-model price in USD / million tokens (input, output). Cache tiers are
|
||||
# multiples of the input price (read ≈ 0.1×, 5m write ≈ 1.25×, 1h write ≈ 2×).
|
||||
# `qwen` covers the pi-harness OpenRouter sessions; a *bare* qwen id (no vendor
|
||||
# prefix) is the local EVOX2 LM Studio provider, which is free. These are the
|
||||
# fallback estimates — a pi transcript record carrying the runner-mirrored
|
||||
# `costUSD` (pi's own per-message provider cost) overrides them (see `feed`).
|
||||
# `qwen` is the *last-resort* estimate for a pi-harness OpenRouter session whose
|
||||
# model isn't in the catalogue; a *bare* qwen id (no vendor prefix) is the local
|
||||
# EVOX2 LM Studio provider, which is free. These are the fallback estimates — a
|
||||
# pi transcript record carrying the runner-mirrored `costUSD` (pi's own
|
||||
# per-message provider cost) overrides them (see `feed`).
|
||||
PRICES = {"opus": (5.0, 25.0), "sonnet": (3.0, 15.0), "haiku": (1.0, 5.0),
|
||||
"fable": (10.0, 50.0), "qwen": (0.15, 1.0), "qwen-local": (0.0, 0.0)}
|
||||
# What a cache read costs, as a multiple of the input rate — Anthropic's flat
|
||||
# 10%. OpenRouter models carry their own absolute cache price instead (see
|
||||
# `rates_for`), because a cache read there is per-model policy, not a fixed cut.
|
||||
CACHE_READ_MULT = 0.1
|
||||
MAX_BLOCK = 8000 # cap a single text/result block so payloads stay bounded
|
||||
|
||||
|
||||
@@ -456,19 +463,40 @@ def _real_model(model: str | None) -> bool:
|
||||
return bool(model) and not model.startswith("<")
|
||||
|
||||
|
||||
def price_for(model: str) -> tuple[float, float]:
|
||||
def rates_for(model: str) -> tuple[float, float, float]:
|
||||
"""(input, output, cache-read) price in USD / million tokens for a model.
|
||||
|
||||
**A pi-harness session is priced from the OpenRouter catalogue** (APPE's
|
||||
daily models.dev sync — see `openrouter.py`), so a run on any of the several
|
||||
hundred pickable models is costed at that model's real rates, cache read
|
||||
included. Only an id the catalogue doesn't know falls through to the flat
|
||||
qwen estimate below. Claude models keep the built-in table and the 10%
|
||||
cache-read cut.
|
||||
"""
|
||||
m = (model or "").lower()
|
||||
if "/" in m: # vendor-prefixed ⇒ an OpenRouter model on the pi harness
|
||||
hit = openrouter.rates(model)
|
||||
if hit:
|
||||
return hit
|
||||
if "sonnet" in m:
|
||||
return PRICES["sonnet"]
|
||||
if "haiku" in m:
|
||||
return PRICES["haiku"]
|
||||
if "fable" in m or "mythos" in m:
|
||||
return PRICES["fable"]
|
||||
if "qwen" in m:
|
||||
# Vendor-prefixed ids (qwen/…) run on OpenRouter; bare ids are the
|
||||
# local EVOX2 LM Studio provider — free.
|
||||
return PRICES["qwen"] if "/" in m else PRICES["qwen-local"]
|
||||
return PRICES["opus"]
|
||||
pi, po = PRICES["sonnet"]
|
||||
elif "haiku" in m:
|
||||
pi, po = PRICES["haiku"]
|
||||
elif "fable" in m or "mythos" in m:
|
||||
pi, po = PRICES["fable"]
|
||||
elif "qwen" in m or "/" in m:
|
||||
# Vendor-prefixed ids run on OpenRouter (priced above when known); bare
|
||||
# qwen ids are the local EVOX2 LM Studio provider — free.
|
||||
pi, po = PRICES["qwen"] if "/" in m else PRICES["qwen-local"]
|
||||
else:
|
||||
pi, po = PRICES["opus"]
|
||||
return pi, po, pi * CACHE_READ_MULT
|
||||
|
||||
|
||||
def price_for(model: str) -> tuple[float, float]:
|
||||
"""(input, output) $/Mtok — `rates_for` without the cache-read rate."""
|
||||
pi, po, _ = rates_for(model)
|
||||
return pi, po
|
||||
|
||||
|
||||
def norm_usage(u: dict) -> dict:
|
||||
@@ -500,19 +528,20 @@ def usage_tokens(u: dict) -> int:
|
||||
|
||||
|
||||
def usage_cost(u: dict, model: str) -> float:
|
||||
pi, po = price_for(model)
|
||||
pi, po, pc = rates_for(model)
|
||||
pi /= 1e6
|
||||
po /= 1e6
|
||||
pc /= 1e6
|
||||
return (u["input"] * pi + u["cacheWriteUnits"] * pi
|
||||
+ u["cacheRead"] * 0.1 * pi + u["output"] * po)
|
||||
+ u["cacheRead"] * pc + u["output"] * po)
|
||||
|
||||
|
||||
def usage_cache_cost(u: dict, model: str) -> float:
|
||||
"""The share of a turn's cost that is replayed cached prompt (billed at 10%
|
||||
of the input rate). The rest is fresh tokens the model actually had to read
|
||||
or write this turn."""
|
||||
pi, _ = price_for(model)
|
||||
return u["cacheRead"] * 0.1 * (pi / 1e6)
|
||||
of the input rate on Claude, at the model's own cache rate on OpenRouter).
|
||||
The rest is fresh tokens the model actually had to read or write this turn."""
|
||||
_, _, pc = rates_for(model)
|
||||
return u["cacheRead"] * (pc / 1e6)
|
||||
|
||||
|
||||
def _block_text(content) -> str:
|
||||
|
||||
@@ -45,6 +45,7 @@ import gitdiff
|
||||
import goal as goalmd
|
||||
import memories as memories_mod
|
||||
import models as models_mod
|
||||
import openrouter as openrouter_mod
|
||||
import cron as cron_mod
|
||||
import ctas as ctas_mod
|
||||
import forms as forms_mod
|
||||
@@ -2053,6 +2054,22 @@ def list_models():
|
||||
"default": models_mod.DEFAULT_MODEL}
|
||||
|
||||
|
||||
@app.get("/api/models/openrouter",
|
||||
responses=_r(schemas.OpenRouterModelsResponse))
|
||||
def list_openrouter_models():
|
||||
"""Every model a pi.dev session can run on OpenRouter, cheapest first.
|
||||
|
||||
Not part of `/api/models` on purpose: that endpoint is the composer's short
|
||||
pickable list (one chip per Claude family + the curated pi entries), while
|
||||
this is a few hundred models the model browser searches through. Prices
|
||||
($/Mtok in / out / cache), context window and parameter size all come from
|
||||
APPE's catalogue — the homelab's model-data source of truth (openrouter.py).
|
||||
"""
|
||||
return {"models": openrouter_mod.catalogue(),
|
||||
"source": openrouter_mod.CATALOGUE_URL,
|
||||
"generatedAt": openrouter_mod.generated_at()}
|
||||
|
||||
|
||||
class SpawnBody(BaseModel):
|
||||
prompt: str
|
||||
model: str | None = None
|
||||
@@ -4027,6 +4044,10 @@ def _startup():
|
||||
threading.Thread(target=_watch_sidecar_runs, daemon=True).start()
|
||||
# Cron jobs: fire scheduled agent sessions (see cron.py).
|
||||
cron_scheduler.start()
|
||||
# OpenRouter catalogue (prices + sizes from APPE): pulled off the request
|
||||
# path so the first model-browser open and the first priced transcript
|
||||
# already have it (openrouter.py).
|
||||
openrouter_mod.warm()
|
||||
# Conversation CTAs: the buttons a finished conversation offers (ctas.py).
|
||||
# The seeded Complete prompt is written into the workspace on first run;
|
||||
# there is no scheduler — a CTA runs when it is pressed.
|
||||
|
||||
194
backend/openrouter.py
Normal file
194
backend/openrouter.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
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
|
||||
@@ -1153,6 +1153,37 @@ class ModelsResponse(Schema):
|
||||
default: str # model a run uses when nothing is picked
|
||||
|
||||
|
||||
class OpenRouterModel(Schema):
|
||||
"""One OpenRouter model a pi.dev session can be spawned on.
|
||||
|
||||
The catalogue behind it is APPE's (models.dev, synced daily) — see
|
||||
`backend/openrouter.py`. Prices are USD per **million** tokens.
|
||||
"""
|
||||
id: str # the `--model` argument, e.g. "openai/gpt-oss-20b"
|
||||
name: str # display name ("GPT OSS 20B")
|
||||
vendor: str # the id's first segment ("openai")
|
||||
description: str = ""
|
||||
inputCost: float
|
||||
outputCost: float
|
||||
# Cache-read price. None = the model has no prompt cache (a cache read is
|
||||
# then billed as a fresh read), which is a different fact from a free cache.
|
||||
cacheCost: Optional[float] = None
|
||||
contextWindow: Optional[int] = None
|
||||
# Parameters in billions, mined from the id/name by APPE. None for models
|
||||
# that publish no size (most closed ones).
|
||||
modelSize: Optional[float] = None
|
||||
tags: list[str] = [] # vision | reasoning | tools | opensource | …
|
||||
tier: str = "" # small | medium | big (blended-price heuristic)
|
||||
license: str = ""
|
||||
speedTps: Optional[float] = None # median output tokens/sec
|
||||
|
||||
|
||||
class OpenRouterModelsResponse(Schema):
|
||||
models: list[OpenRouterModel] # cheapest first (blended 3:1 in:out)
|
||||
source: str # the APPE URL the catalogue came from
|
||||
generatedAt: str = "" # when APPE generated it (ISO 8601)
|
||||
|
||||
|
||||
class SpawnResult(Schema):
|
||||
sessionId: str
|
||||
pid: Optional[int] = None
|
||||
|
||||
@@ -1122,6 +1122,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/models/openrouter": {
|
||||
"get": {
|
||||
"summary": "List Openrouter Models",
|
||||
"description": "Every model a pi.dev session can run on OpenRouter, cheapest first.\n\nNot part of `/api/models` on purpose: that endpoint is the composer's short\npickable list (one chip per Claude family + the curated pi entries), while\nthis is a few hundred models the model browser searches through. Prices\n($/Mtok in / out / cache), context window and parameter size all come from\nAPPE's catalogue \u2014 the homelab's model-data source of truth (openrouter.py).",
|
||||
"operationId": "list_openrouter_models_api_models_openrouter_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OpenRouterModelsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/spawn": {
|
||||
"post": {
|
||||
"summary": "Spawn Conversation",
|
||||
@@ -9461,6 +9480,133 @@
|
||||
],
|
||||
"title": "OkResponse"
|
||||
},
|
||||
"OpenRouterModel": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"title": "Vendor"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"title": "Description",
|
||||
"default": ""
|
||||
},
|
||||
"inputCost": {
|
||||
"type": "number",
|
||||
"title": "Inputcost"
|
||||
},
|
||||
"outputCost": {
|
||||
"type": "number",
|
||||
"title": "Outputcost"
|
||||
},
|
||||
"cacheCost": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Cachecost"
|
||||
},
|
||||
"contextWindow": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Contextwindow"
|
||||
},
|
||||
"modelSize": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Modelsize"
|
||||
},
|
||||
"tags": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Tags",
|
||||
"default": []
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"title": "Tier",
|
||||
"default": ""
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"title": "License",
|
||||
"default": ""
|
||||
},
|
||||
"speedTps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Speedtps"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"vendor",
|
||||
"inputCost",
|
||||
"outputCost"
|
||||
],
|
||||
"title": "OpenRouterModel",
|
||||
"description": "One OpenRouter model a pi.dev session can be spawned on.\n\nThe catalogue behind it is APPE's (models.dev, synced daily) \u2014 see\n`backend/openrouter.py`. Prices are USD per **million** tokens."
|
||||
},
|
||||
"OpenRouterModelsResponse": {
|
||||
"properties": {
|
||||
"models": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/OpenRouterModel"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Models"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"title": "Source"
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"title": "Generatedat",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"models",
|
||||
"source"
|
||||
],
|
||||
"title": "OpenRouterModelsResponse"
|
||||
},
|
||||
"ParentConvRef": {
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
MemoryDetail,
|
||||
MemorySummary,
|
||||
ModelsResponse,
|
||||
OpenRouterModelsResponse,
|
||||
NotifyLogEntry,
|
||||
NotifyResult,
|
||||
EnvSaveBody,
|
||||
@@ -112,6 +113,16 @@ export async function fetchModels(): Promise<ModelsResponse> {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** Every OpenRouter model a pi.dev session can run on, with its prices — the
|
||||
* model browser's catalogue (sourced from APPE; see backend/openrouter.py). */
|
||||
export async function fetchOpenRouterModels(): Promise<OpenRouterModelsResponse> {
|
||||
const r = await apiFetch("/api/models/openrouter", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!r.ok) throw new Error(`openrouter models: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export interface ConversationsPage {
|
||||
conversations: ConversationSummary[];
|
||||
/** Total conversations matching the filter (pre-pagination). */
|
||||
|
||||
192
frontend/src/business/composer/components/OpenRouterBrowser.tsx
Normal file
192
frontend/src/business/composer/components/OpenRouterBrowser.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { EmptyState, FuzzyList, Modal, TagFilter, type FuzzyRenderContext } from "@gabvdl/ui";
|
||||
import { Cpu, Pi } from "lucide-react";
|
||||
import { useOpenRouterModels } from "@/lib/models";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { OpenRouterModel } from "@/types";
|
||||
|
||||
/**
|
||||
* The OpenRouter model browser — the pi.dev harness's real model picker.
|
||||
*
|
||||
* The composer's select lists a handful of curated entries; OpenRouter serves
|
||||
* several hundred, and choosing between them is a **price** decision more than a
|
||||
* name decision. So this is a fuzzy list where every row states the three rates
|
||||
* that actually bill an agent run (input / output / cache-read, $ per million
|
||||
* tokens), the model's parameter count and its context window — the numbers you
|
||||
* would otherwise go to OpenRouter's site to compare.
|
||||
*
|
||||
* The catalogue comes from APPE (`/api/models/openrouter` → `backend/openrouter.py`
|
||||
* → appe.dev.gabvdl.xyz), which syncs models.dev daily. Cheapest first, so an
|
||||
* unfiltered open lands on what is cheapest to try; typing re-ranks by relevance.
|
||||
*/
|
||||
|
||||
/** `$/Mtok` as a compact figure: sub-cent rates keep their significant digits. */
|
||||
export function usd(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
if (n === 0) return "free";
|
||||
if (n < 0.01) return `$${n.toFixed(4).replace(/0+$/, "")}`;
|
||||
if (n < 1) return `$${n.toFixed(3).replace(/0+$/, "")}`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** "35B" / "1.5B" — a model's parameter count, or null when it publishes none. */
|
||||
export function paramsLabel(size: number | null | undefined): string | null {
|
||||
if (!size) return null;
|
||||
return size >= 1 ? `${Number(size.toFixed(1))}B` : `${Math.round(size * 1000)}M`;
|
||||
}
|
||||
|
||||
/** "262k" / "1M" — a context window in tokens. */
|
||||
export function ctxLabel(ctx: number | null | undefined): string | null {
|
||||
if (!ctx) return null;
|
||||
if (ctx >= 1_000_000) return `${Number((ctx / 1_000_000).toFixed(1))}M`;
|
||||
return ctx >= 1000 ? `${Math.round(ctx / 1000)}k` : `${ctx}`;
|
||||
}
|
||||
|
||||
/** The capability facets worth filtering on (a row can carry several). */
|
||||
const FACETS = [
|
||||
{ value: "tools", label: "Tools" },
|
||||
{ value: "reasoning", label: "Reasoning" },
|
||||
{ value: "vision", label: "Vision" },
|
||||
{ value: "opensource", label: "Open weights" },
|
||||
{ value: "free", label: "Free" },
|
||||
] as const;
|
||||
|
||||
/** One price cell — label above, figure below, so three fit in a row's tail. */
|
||||
function Rate({ label, value, muted }: { label: string; value: string; muted?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-[3.5rem] flex-col items-end leading-tight">
|
||||
<span className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className={cn("font-mono text-[11px]", muted ? "text-muted-foreground" : "text-foreground")}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenRouterBrowser({
|
||||
open,
|
||||
picked,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The `--model` argument currently in the composer, highlighted in the list. */
|
||||
picked?: string | null;
|
||||
onPick: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, isLoading, error } = useOpenRouterModels(open);
|
||||
const [facets, setFacets] = useState<string[]>([]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data?.models ?? [];
|
||||
if (!facets.length) return all;
|
||||
return all.filter((m) =>
|
||||
facets.every((f) =>
|
||||
f === "free" ? m.inputCost === 0 && m.outputCost === 0 : (m.tags ?? []).includes(f),
|
||||
),
|
||||
);
|
||||
}, [data, facets]);
|
||||
|
||||
const renderRow = ({ item, active, highlight }: FuzzyRenderContext<OpenRouterModel>) => {
|
||||
const params = paramsLabel(item.modelSize);
|
||||
const ctx = ctxLabel(item.contextWindow);
|
||||
const isPicked = picked === item.id;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2 transition-colors",
|
||||
active ? "border-primary/50 bg-primary/5" : "border-transparent hover:bg-muted/50",
|
||||
isPicked && "border-teal-500/50 bg-teal-500/5",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{highlight("name")}</span>
|
||||
{params && <Chip>{params}</Chip>}
|
||||
{ctx && <Chip>{ctx} ctx</Chip>}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
{highlight("id")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Rate label="in" value={usd(item.inputCost)} />
|
||||
<Rate label="out" value={usd(item.outputCost)} />
|
||||
{/* No cache price = no prompt cache: a "cache read" is billed as a
|
||||
fresh read, which is why it shows the input rate, greyed. */}
|
||||
<Rate
|
||||
label="cache"
|
||||
value={usd(item.cacheCost ?? item.inputCost)}
|
||||
muted={item.cacheCost == null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="OpenRouter models"
|
||||
description="Prices are USD per million tokens, from APPE's daily models.dev sync."
|
||||
size="lg"
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col p-3"
|
||||
className="h-[80dvh] max-h-[80dvh]"
|
||||
>
|
||||
<TagFilter
|
||||
wrap
|
||||
multiple
|
||||
allLabel="All"
|
||||
className="mb-3 shrink-0"
|
||||
items={FACETS.map((f) => ({ id: f.value, label: f.label }))}
|
||||
value={facets}
|
||||
onChange={setFacets}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading the catalogue…</div>
|
||||
) : error || !data?.models.length ? (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={<Cpu className="h-5 w-5" />}
|
||||
title="No catalogue"
|
||||
description="APPE (appe.dev.gabvdl.xyz) couldn't be reached, so the model list is empty. The curated pi entries still work."
|
||||
/>
|
||||
) : (
|
||||
<FuzzyList<OpenRouterModel>
|
||||
items={rows}
|
||||
keys={["name", "id", "vendor", "description"]}
|
||||
getItemKey={(m) => m.id}
|
||||
smooth
|
||||
onSelect={(m) => {
|
||||
onPick(m.id);
|
||||
onClose();
|
||||
}}
|
||||
placeholder={'Search models… (use "quotes" for exact)'}
|
||||
estimateSize={62}
|
||||
overscan={8}
|
||||
showCount
|
||||
emptyState={
|
||||
<EmptyState
|
||||
compact
|
||||
icon={<Pi className="h-5 w-5" />}
|
||||
title="No model matches"
|
||||
description="Try another search, or clear the capability filters."
|
||||
/>
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
renderItem={renderRow}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,7 @@ import type {
|
||||
NotifyLogResponse,
|
||||
NotifyResult,
|
||||
OkResponse,
|
||||
OpenRouterModelsResponse,
|
||||
PlanDetail,
|
||||
PlanDetailApiPlanGetParams,
|
||||
PlansResponse,
|
||||
@@ -1733,6 +1734,56 @@ export const listModelsApiModelsGet = async ( options?: RequestInit): Promise<li
|
||||
|
||||
|
||||
|
||||
export type listOpenrouterModelsApiModelsOpenrouterGetResponse200 = {
|
||||
data: OpenRouterModelsResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type listOpenrouterModelsApiModelsOpenrouterGetResponseSuccess = (listOpenrouterModelsApiModelsOpenrouterGetResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
;
|
||||
|
||||
export type listOpenrouterModelsApiModelsOpenrouterGetResponse = (listOpenrouterModelsApiModelsOpenrouterGetResponseSuccess)
|
||||
|
||||
export const getListOpenrouterModelsApiModelsOpenrouterGetUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/models/openrouter`
|
||||
}
|
||||
|
||||
/**
|
||||
* Every model a pi.dev session can run on OpenRouter, cheapest first.
|
||||
*
|
||||
* Not part of `/api/models` on purpose: that endpoint is the composer's short
|
||||
* pickable list (one chip per Claude family + the curated pi entries), while
|
||||
* this is a few hundred models the model browser searches through. Prices
|
||||
* ($/Mtok in / out / cache), context window and parameter size all come from
|
||||
* APPE's catalogue — the homelab's model-data source of truth (openrouter.py).
|
||||
* @summary List Openrouter Models
|
||||
*/
|
||||
export const listOpenrouterModelsApiModelsOpenrouterGet = async ( options?: RequestInit): Promise<listOpenrouterModelsApiModelsOpenrouterGetResponse> => {
|
||||
|
||||
const res = await fetch(getListOpenrouterModelsApiModelsOpenrouterGetUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: listOpenrouterModelsApiModelsOpenrouterGetResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as listOpenrouterModelsApiModelsOpenrouterGetResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type spawnConversationApiSpawnPostResponse200 = {
|
||||
data: SpawnResult
|
||||
status: 200
|
||||
|
||||
@@ -154,6 +154,8 @@ export * from './notifyResult.ts';
|
||||
export * from './ogImage.ts';
|
||||
export * from './ogImageKind.ts';
|
||||
export * from './okResponse.ts';
|
||||
export * from './openRouterModel.ts';
|
||||
export * from './openRouterModelsResponse.ts';
|
||||
export * from './parentConvRef.ts';
|
||||
export * from './planActual.ts';
|
||||
export * from './planConvRef.ts';
|
||||
|
||||
28
frontend/src/generated/model/openRouterModel.ts
Normal file
28
frontend/src/generated/model/openRouterModel.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* One OpenRouter model a pi.dev session can be spawned on.
|
||||
*
|
||||
* The catalogue behind it is APPE's (models.dev, synced daily) — see
|
||||
* `backend/openrouter.py`. Prices are USD per **million** tokens.
|
||||
*/
|
||||
export interface OpenRouterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
description?: string;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
cacheCost?: number | null;
|
||||
contextWindow?: number | null;
|
||||
modelSize?: number | null;
|
||||
tags?: string[];
|
||||
tier?: string;
|
||||
license?: string;
|
||||
speedTps?: number | null;
|
||||
}
|
||||
13
frontend/src/generated/model/openRouterModelsResponse.ts
Normal file
13
frontend/src/generated/model/openRouterModelsResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { OpenRouterModel } from './openRouterModel.ts';
|
||||
|
||||
export interface OpenRouterModelsResponse {
|
||||
models: OpenRouterModel[];
|
||||
source: string;
|
||||
generatedAt?: string;
|
||||
}
|
||||
@@ -34,8 +34,10 @@ const DAY_MS = 86_400_000;
|
||||
const MONTH_MS = 30.4375 * DAY_MS;
|
||||
|
||||
// ── pricing ───────────────────────────────────────────────────────────────────
|
||||
// Mirrors the backend's `conversations.PRICES` (USD per million tokens, input /
|
||||
// output). Cache reads bill at 0.1× input; cache writes are priced off their
|
||||
// Mirrors the backend's `conversations.PRICES` **fallback** table (USD per
|
||||
// million tokens, input / output) — the backend additionally prices OpenRouter
|
||||
// models from APPE's catalogue, per model. Cache reads bill at 0.1× input (an
|
||||
// OpenRouter model's own cache rate can differ); cache writes are priced off their
|
||||
// weighted `cacheWriteUnits` (5m ≈ 1.25×, 1h ≈ 2×) at the input rate. Kept
|
||||
// client-side so the dashboard can split a conversation's single `cost` figure
|
||||
// back into per-token-type shares.
|
||||
@@ -53,8 +55,15 @@ function priceFor(model: string | null | undefined): [number, number] {
|
||||
if (m.includes("sonnet")) return PRICES.sonnet;
|
||||
if (m.includes("haiku")) return PRICES.haiku;
|
||||
if (m.includes("fable") || m.includes("mythos")) return PRICES.fable;
|
||||
if (m.includes("qwen"))
|
||||
return m.includes("/") ? PRICES.qwen : PRICES["qwen-local"];
|
||||
if (m.includes("/")) {
|
||||
// A vendor-prefixed id is an OpenRouter model on the pi harness. Its *real*
|
||||
// rates come from the catalogue backend-side (`backend/openrouter.py` prices
|
||||
// each turn, and that is what a conversation's `cost` already reflects);
|
||||
// this table only splits that figure across token types, so a single open-
|
||||
// model estimate is enough — and beats charging it at Opus rates.
|
||||
return PRICES.qwen;
|
||||
}
|
||||
if (m.includes("qwen")) return PRICES["qwen-local"];
|
||||
return PRICES.opus;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Bot,
|
||||
@@ -8,11 +9,13 @@ import {
|
||||
Gauge,
|
||||
Pi,
|
||||
Rabbit,
|
||||
Search,
|
||||
Sparkles,
|
||||
SquareTerminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { fetchModels } from "@/api";
|
||||
import { fetchModels, fetchOpenRouterModels } from "@/api";
|
||||
import { OpenRouterBrowser } from "@/business/composer/components/OpenRouterBrowser";
|
||||
import { SelectPopover, type SelectOption } from "@/technical/SelectPopover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModelInfo } from "@/types";
|
||||
@@ -41,6 +44,10 @@ import type { ModelInfo } from "@/types";
|
||||
/** The agent CLI a session runs on. */
|
||||
export type Harness = "claude" | "pi";
|
||||
|
||||
/** Sentinel option value: "Browse OpenRouter…" opens the catalogue modal
|
||||
* instead of picking a model. Can't collide with a real id (no slash). */
|
||||
const BROWSE = "__browse_openrouter__";
|
||||
|
||||
/** Per-harness label + icon for the harness select. */
|
||||
export const HARNESS_STYLE: Record<Harness, { label: string; hint: string; icon: typeof Bot }> = {
|
||||
claude: {
|
||||
@@ -65,14 +72,27 @@ const FAMILY_STYLE: Record<string, { cls: string; icon: typeof Bot }> = {
|
||||
qwen: { cls: "bg-sky-500/10 text-sky-500", icon: Cpu },
|
||||
};
|
||||
const UNKNOWN = { cls: "bg-muted text-muted-foreground", icon: Bot };
|
||||
// Any vendor-prefixed id the families above don't claim — i.e. one of the few
|
||||
// hundred OpenRouter models the browser can pick (see OpenRouterBrowser).
|
||||
const OPENROUTER_STYLE = { cls: "bg-sky-500/10 text-sky-500", icon: Cpu };
|
||||
|
||||
/** The family a model id belongs to (`claude-opus-4-8` → `opus`). */
|
||||
export function modelFamily(id: string): string {
|
||||
return Object.keys(FAMILY_STYLE).find((f) => id.includes(f)) ?? "";
|
||||
}
|
||||
|
||||
/** Whether a `--model` argument is an OpenRouter model (`vendor/model`).
|
||||
* Claude ids never carry a slash, so the shape alone is the test — which is
|
||||
* what lets a conversation resumed on an arbitrary OpenRouter model still
|
||||
* resolve to the pi harness without the catalogue being loaded. */
|
||||
export function isOpenRouterId(id: string | null | undefined): boolean {
|
||||
return !!id && id.includes("/");
|
||||
}
|
||||
|
||||
export function modelStyle(id: string) {
|
||||
return FAMILY_STYLE[modelFamily(id)] ?? UNKNOWN;
|
||||
const family = modelFamily(id);
|
||||
if (family) return FAMILY_STYLE[family];
|
||||
return isOpenRouterId(id) ? OPENROUTER_STYLE : UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +108,9 @@ export function modelLabel(id: string): string {
|
||||
const ver = tail.match(/qwen(\d+(?:\.\d+)?)/)?.[1];
|
||||
return ver ? `qwen ${ver}` : tail;
|
||||
}
|
||||
if (!family) return id.replace("claude-", "");
|
||||
// An OpenRouter pick: the vendor prefix is the least informative half of
|
||||
// `openai/gpt-oss-20b`, and the chip is narrow — show the model, not the shop.
|
||||
if (!family) return isOpenRouterId(id) ? (id.split("/").pop() as string) : id.replace("claude-", "");
|
||||
const version = id
|
||||
.split(`${family}-`)[1]
|
||||
?.split("-")
|
||||
@@ -126,6 +148,25 @@ export function useModels() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenRouter catalogue — every model the **pi** harness can run, with its
|
||||
* $/Mtok (input / output / cache-read), parameter size and context window.
|
||||
*
|
||||
* Deliberately a second query rather than more entries in `useModels()`: that
|
||||
* one is the composer's short pickable list (a chip each), this is a few hundred
|
||||
* rows only the browser modal renders, so it is fetched when the modal first
|
||||
* opens and then cached for the app session. The data itself is APPE's — see
|
||||
* `backend/openrouter.py`.
|
||||
*/
|
||||
export function useOpenRouterModels(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["models", "openrouter"],
|
||||
queryFn: fetchOpenRouterModels,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--model` argument for a model entry: a family's newest model goes out as
|
||||
* its CLI alias (`opus`), older ones as their full id.
|
||||
@@ -156,7 +197,9 @@ export function harnessForModel(
|
||||
if (!arg) return "claude";
|
||||
const hit = findModel(models, arg);
|
||||
if (hit?.harness) return hit.harness === "pi" ? "pi" : "claude";
|
||||
return modelFamily(arg) === "qwen" ? "pi" : "claude";
|
||||
// Not in the list: an older transcript's model, or one of the several hundred
|
||||
// OpenRouter models the browser can pick (which the short list never carries).
|
||||
return isOpenRouterId(arg) || modelFamily(arg) === "qwen" ? "pi" : "claude";
|
||||
}
|
||||
|
||||
/** The models a harness can run, in backend order (newest of each family first). */
|
||||
@@ -242,6 +285,7 @@ export function ModelSelect({
|
||||
onChange: (arg: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const list = modelsForHarness(models, harness);
|
||||
const options: SelectOption[] = list.map((m) => {
|
||||
const { icon: Icon, cls } = modelStyle(m.id);
|
||||
@@ -256,19 +300,43 @@ export function ModelSelect({
|
||||
// What the chip shows: the pick, else the harness's first model (what the run
|
||||
// will actually use) — never a blank chip.
|
||||
const shown = model || (list[0] ? modelArg(list[0]) : "");
|
||||
if (!shown) {
|
||||
// A model picked from the OpenRouter browser isn't in the short list, so it
|
||||
// gets its own option — otherwise the popover would show no pick at all.
|
||||
if (shown && isOpenRouterId(shown) && !options.some((o) => o.value === shown)) {
|
||||
const { icon: Icon, cls } = modelStyle(shown);
|
||||
options.unshift({
|
||||
value: shown,
|
||||
label: modelLabel(shown),
|
||||
hint: shown,
|
||||
icon: <Icon className={cn("h-3.5 w-3.5", cls.split(" ")[1])} />,
|
||||
meta: "OpenRouter",
|
||||
});
|
||||
}
|
||||
// The pi harness's real catalogue is the browser, not this list: several
|
||||
// hundred models, picked by price. The select keeps the curated entries and
|
||||
// hands off to the modal for everything else.
|
||||
if (harness === "pi") {
|
||||
options.push({
|
||||
value: BROWSE,
|
||||
label: "Browse OpenRouter…",
|
||||
hint: "every model, with its $/Mtok and size",
|
||||
icon: <Search className="h-3.5 w-3.5 text-muted-foreground" />,
|
||||
});
|
||||
}
|
||||
if (!shown && harness !== "pi") {
|
||||
return <span className="text-[10px] text-muted-foreground">default model</span>;
|
||||
}
|
||||
const { icon: Icon, cls } = modelStyle(shown);
|
||||
const entry = findModel(models, shown);
|
||||
return (
|
||||
<>
|
||||
<SelectPopover
|
||||
title="Model"
|
||||
width={250}
|
||||
options={options}
|
||||
value={shown}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
onChange={(v) => (v === BROWSE ? setBrowsing(true) : onChange(v))}
|
||||
trigger={
|
||||
<span
|
||||
title={`Runs on ${entry?.id ?? shown} — click to pick another model`}
|
||||
@@ -278,10 +346,19 @@ export function ModelSelect({
|
||||
)}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{entry?.label || modelLabel(shown)}
|
||||
{entry?.label || modelLabel(shown) || "pick a model"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{browsing && (
|
||||
<OpenRouterBrowser
|
||||
open
|
||||
picked={shown}
|
||||
onPick={onChange}
|
||||
onClose={() => setBrowsing(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
MemoryDetail,
|
||||
MemorySummary,
|
||||
ModelsResponse,
|
||||
OpenRouterModelsResponse,
|
||||
NotifyLogEntry,
|
||||
PlanDetail,
|
||||
PlanSummary,
|
||||
@@ -42,6 +43,8 @@ export interface MockDb {
|
||||
workspace: string;
|
||||
pricing: Pricing;
|
||||
models: ModelsResponse;
|
||||
/** The OpenRouter catalogue the pi-harness model browser searches. */
|
||||
openRouterModels: OpenRouterModelsResponse;
|
||||
files: FileEntry[];
|
||||
conversations: ConversationDetail[];
|
||||
/** Per-conversation commit rollup + net diff, keyed by conversation id. */
|
||||
|
||||
@@ -302,6 +302,7 @@ export async function handle(url: URL, init: RequestInit | undefined, request?:
|
||||
if (p === "/api/deploy-status") return json(db.deployStatus);
|
||||
if (p === "/api/bundle") return json({ workspace: db.workspace, totals: totals(), files: db.files, pricing: db.pricing });
|
||||
if (p === "/api/models") return json(db.models);
|
||||
if (p === "/api/models/openrouter") return json(db.openRouterModels);
|
||||
if (p === "/api/dashboard") {
|
||||
const t = totals();
|
||||
return json({
|
||||
|
||||
@@ -1425,6 +1425,20 @@ export function seedDb(): MockDb {
|
||||
{ id: "qwen3-vl-8b", displayName: "Qwen3 VL 8B", family: "qwen", version: "3", alias: null, latest: false, harness: "pi", provider: "lmstudio", label: "Qwen VL", contextWindow: 32_000 },
|
||||
],
|
||||
},
|
||||
// A handful of the OpenRouter catalogue (the real one is ~340 models from
|
||||
// APPE) — enough for the model browser to search, filter and price in mock
|
||||
// mode. Cheapest first, like the API returns it.
|
||||
openRouterModels: {
|
||||
source: "https://appe.dev.gabvdl.xyz/api/models/openrouter.json",
|
||||
generatedAt: "2026-08-01T04:30:00.000Z",
|
||||
models: [
|
||||
{ id: "google/gemma-4-31b-it:free", name: "Gemma 4 31B (free)", vendor: "google", description: "Open Gemma instruction model for self-hosted chat and reasoning", inputCost: 0, outputCost: 0, cacheCost: null, contextWindow: 262_144, modelSize: 31, tags: ["vision", "reasoning", "tools", "opensource"], tier: "small", license: "opensource", speedTps: 120 },
|
||||
{ id: "mistralai/mistral-nemo", name: "Mistral Nemo", vendor: "mistralai", description: "Small multilingual instruct model", inputCost: 0.019, outputCost: 0.03, cacheCost: null, contextWindow: 131_072, modelSize: null, tags: ["tools", "opensource"], tier: "small", license: "opensource", speedTps: 190 },
|
||||
{ id: "openai/gpt-oss-20b", name: "GPT OSS 20B", vendor: "openai", description: "Open-weight GPT model for self-hosted reasoning", inputCost: 0.03, outputCost: 0.13, cacheCost: 0.03, contextWindow: 131_072, modelSize: 20, tags: ["reasoning", "tools", "opensource"], tier: "small", license: "opensource", speedTps: 258 },
|
||||
{ id: "qwen/qwen3-235b-a22b", name: "Qwen3 235B A22B", vendor: "qwen", description: "Large MoE reasoning model", inputCost: 0.13, outputCost: 0.6, cacheCost: 0.02, contextWindow: 262_144, modelSize: 235, tags: ["reasoning", "tools", "opensource"], tier: "small", license: "opensource", speedTps: 88 },
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", vendor: "anthropic", description: "Balanced frontier model", inputCost: 3, outputCost: 15, cacheCost: 0.3, contextWindow: 1_000_000, modelSize: null, tags: ["vision", "reasoning", "tools"], tier: "medium", license: "commercial", speedTps: 71 },
|
||||
],
|
||||
},
|
||||
files,
|
||||
conversations,
|
||||
commits,
|
||||
|
||||
@@ -48,11 +48,16 @@ composer chip → POST /api/spawn {model, harness}
|
||||
|
||||
| model id | provider |
|
||||
|---|---|
|
||||
| `qwen/qwen3.6-35b-a3b` (vendor prefix) | OpenRouter (`OPENROUTER_API_KEY`, auto-loaded from `~/homelab/.env.claude`) |
|
||||
| `openai/gpt-oss-20b`, `qwen/qwen3.6-35b-a3b`, … (vendor prefix) | OpenRouter (`OPENROUTER_API_KEY`, auto-loaded from `~/homelab/.env.claude`) |
|
||||
| `qwen3.6-35b-a3b` (bare) | EVOX2 LM Studio (`~/.pi/agent/models.json` `evox2` provider; box is WoL-woken automatically) |
|
||||
|
||||
The pickable list lives in `backend/models.py` (`PI_MODELS_DEFAULT`, override
|
||||
with `PI_MODELS_JSON`).
|
||||
The routing is the **id shape**, not a list — so any of the several hundred
|
||||
OpenRouter models runs here with no runner change. Two pickers feed it: the
|
||||
curated chips in `backend/models.py` (`PI_MODELS_DEFAULT`, override with
|
||||
`PI_MODELS_JSON`) and the composer's **OpenRouter browser**, which lists the
|
||||
whole catalogue with per-model prices from `backend/openrouter.py` (sourced from
|
||||
APPE). Those same prices cost the session's transcript, so a run's `$` figure is
|
||||
the model's real rate even before pi reports its own `costUSD`.
|
||||
|
||||
## Permissions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user