Files
ai-agent/backend/models.py
Gabriel Vidal 18b9888841 feat(ai-agent): harness-aware backend — pi models, dual transcript sources, qwen pricing
/api/spawn + /api/resume accept harness ("claude"|"pi"), stamp it into the
conversation meta sidecar, and resume falls back to the harness the session
started on. /api/models now merges the Anthropic list with pi-harness entries
(qwen 3.6 via OpenRouter + EVOX2 LM Studio local; PI_MODELS_JSON override),
each entry tagged harness/provider/label. The indexer + SSE watcher accept a
list of transcript sources — the Claude Code projects dir plus the runner's
PI_TRANSCRIPTS_DIR mirror (new ro mount) — imported into one archive.
conversations.py prices the qwen family (OpenRouter rates; local ≈ free) and
ConvMeta carries the harness to the UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 04:40:25 +02:00

192 lines
7.7 KiB
Python

"""
The Claude models a spawned session can run on.
The composer lets a run be pointed at a specific model (a tag per model, e.g.
`opus 4.8` / `haiku 4.5`), and the sidecar passes it to `claude --model`. That
list has to stay current without a redeploy every time Anthropic ships a model,
so it is **fetched from the Anthropic API** (`GET /v1/models`, same key the token
counter already uses) and cached in-process for a few hours. When the key is
missing or the call fails we fall back to a small static list, so the picker
always has something to offer.
**One model per family** — only the newest opus / sonnet / haiku / fable. Older
snapshots are filtered out (see `_shape`): four chips, one per family, is the
whole choice a new session needs.
Each entry carries:
``id`` the exact ``--model`` argument (a versioned model id);
``family`` opus | sonnet | haiku | fable — how the picker groups/colours;
``version`` the human version within the family ("4.8"), for the chip label;
``alias`` the CLI shorthand (``opus``/``sonnet``/``haiku``) — the frontend
sends it instead of the id so a run follows the CLI's own
resolution of "latest";
``latest`` always true now that only the newest of a family is listed.
"""
import json
import os
import re
import threading
import time
import urllib.error
import urllib.request
API_URL = "https://api.anthropic.com/v1/models?limit=100"
API_VERSION = "2023-06-01"
CACHE_TTL_S = 6 * 3600
# The families the picker offers, in display order. Anything the API returns
# outside these (legacy claude-3-*, etc.) is dropped: the point is a short,
# pickable list, not an exhaustive catalogue.
FAMILIES = ("opus", "sonnet", "haiku", "fable")
# Families whose shorthand `claude --model <alias>` understands.
CLI_ALIASES = {"opus", "sonnet", "haiku"}
# What a fresh spawn runs on when the user hasn't picked anything.
DEFAULT_MODEL = os.environ.get("SPAWN_DEFAULT_MODEL", "opus")
# Context-window size (tokens) per family — the fallback when the API doesn't
# carry `max_input_tokens` for a model (or the API is unreachable). Read by the
# conversation page's context-fill gizmo via ModelInfo.contextWindow.
CONTEXT_WINDOWS = {
"opus": 1_000_000,
"sonnet": 1_000_000,
"fable": 1_000_000,
"haiku": 200_000,
"qwen": 262_144,
}
DEFAULT_CONTEXT_WINDOW = 200_000
# ── pi-harness models ────────────────────────────────────────────────────────
# Sessions on the pi.dev harness (services/ai-agent/runner) run open models
# instead of the claude CLI. Static list: ids with a vendor prefix route to
# OpenRouter, bare ids to the EVOX2 LM Studio provider (see runner/cli.mjs).
# Override with PI_MODELS_JSON (a JSON array of the same shape) if needed.
PI_MODELS_DEFAULT = [
{
"id": "qwen/qwen3.6-35b-a3b",
"displayName": "Qwen 3.6 35B-A3B (OpenRouter)",
"family": "qwen", "version": "3.6", "label": "qwen 3.6",
"alias": None, "latest": True,
"harness": "pi", "provider": "openrouter",
"contextWindow": 262_144,
},
{
"id": "qwen3.6-35b-a3b",
"displayName": "Qwen 3.6 35B-A3B (EVOX2 LM Studio, local)",
"family": "qwen", "version": "3.6", "label": "qwen 3.6 local",
"alias": None, "latest": True,
"harness": "pi", "provider": "evox2",
"contextWindow": 32_768,
},
]
def _pi_models() -> list[dict]:
raw = os.environ.get("PI_MODELS_JSON", "")
if raw:
try:
models = json.loads(raw)
if isinstance(models, list):
return models
except ValueError:
pass
return PI_MODELS_DEFAULT
def context_window(model_id: str) -> int:
"""Context-window size for any model id, via its family (static fallback)."""
m = (model_id or "").lower()
for family, size in CONTEXT_WINDOWS.items():
if family in m:
return size
return DEFAULT_CONTEXT_WINDOW
# Used only when the API is unreachable — kept deliberately short.
FALLBACK = [
{"id": "claude-opus-4-8", "displayName": "Claude Opus 4.8"},
{"id": "claude-sonnet-5", "displayName": "Claude Sonnet 5"},
{"id": "claude-fable-5", "displayName": "Claude Fable 5"},
{"id": "claude-haiku-4-5-20251001", "displayName": "Claude Haiku 4.5"},
]
_lock = threading.Lock()
_cache: tuple[float, list[dict]] | None = None
def _version(model_id: str, family: str) -> str:
"""The version inside a family: `claude-opus-4-8` → "4.8", `claude-sonnet-5` → "5".
Model ids carry the version as dash-separated digits after the family, with an
optional trailing snapshot date (`claude-haiku-4-5-20251001`) we drop."""
rest = model_id.split(f"{family}-", 1)[-1] if f"{family}-" in model_id else ""
parts = [p for p in rest.split("-") if p.isdigit()]
if parts and len(parts[-1]) == 8: # trailing YYYYMMDD snapshot
parts = parts[:-1]
return ".".join(parts)
def _shape(raw: list[dict]) -> list[dict]:
"""Turn the API's model records into the picker's entries: one per family.
The API returns newest-first, so a family's first model is its latest — and
that is the only one we keep. Older snapshots (opus 4.7, sonnet 4.5, …) are
dropped: they'd make the tag row a wall of near-identical chips, and there's
no reason to start a fresh session on a superseded model. Reading a *past*
conversation is unaffected — its tags come from the transcript, not this list,
so a run on an older model still renders with its own version."""
out: list[dict] = []
seen_family: set[str] = set()
for m in raw:
mid = m.get("id") or ""
family = next((f for f in FAMILIES if re.search(rf"\b{f}\b", mid)), None)
if not family or family in seen_family:
continue
seen_family.add(family)
out.append({
"id": mid,
"displayName": m.get("displayName") or m.get("display_name") or mid,
"family": family,
"version": _version(mid, family),
"alias": family if family in CLI_ALIASES else None,
"latest": True,
"harness": "claude",
# The API's own window size when present (max_input_tokens, added
# to /v1/models in Mar 2026), else the per-family static value.
"contextWindow": m.get("max_input_tokens")
or CONTEXT_WINDOWS.get(family, DEFAULT_CONTEXT_WINDOW),
})
out.sort(key=lambda e: FAMILIES.index(e["family"]))
return out
def _fetch() -> list[dict]:
key = os.environ.get("ANTHROPIC_API_KEY", "")
if not key:
return _shape(FALLBACK)
req = urllib.request.Request(
API_URL, headers={"x-api-key": key, "anthropic-version": API_VERSION})
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode())
models = _shape(data.get("data") or [])
return models or _shape(FALLBACK)
except (urllib.error.URLError, OSError, ValueError):
return _shape(FALLBACK)
def list_models(force: bool = False) -> list[dict]:
"""The pickable models, cached for {CACHE_TTL_S}s (one API call per TTL).
Claude models (live from the Anthropic API) first, then the pi-harness
models — the composer renders one chip per entry, and picking a pi entry
routes the session through the pi runner instead of the claude CLI."""
global _cache
with _lock:
if not force and _cache and time.time() - _cache[0] < CACHE_TTL_S:
return _cache[1]
models = _fetch() + _pi_models() # outside the lock: slow call, no blocking
with _lock:
_cache = (time.time(), models)
return models