Files
ai-agent/runner/README.md
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

95 lines
4.9 KiB
Markdown

# pi runner — the ai-agent's second harness
Runs viewer-spawned sessions on **[pi](https://pi.dev)** (`@earendil-works/
pi-coding-agent`) instead of the `claude` CLI, with open models: Qwen 3.6 via
**OpenRouter** (hosted) or the **EVOX2 box's LM Studio** (local, free).
## How harness switching works
The **model chip in the composer is the harness switch**. `/api/models` returns
every pickable model tagged with its `harness` (`claude` | `pi`); picking a
qwen chip routes the session through this runner, picking a Claude family runs
`claude` exactly as before. The chain:
```
composer chip → POST /api/spawn {model, harness}
→ sidecar /spawn → harness == "pi" ? RUNNER_BIN : CLAUDE_BIN
→ runner: pi --mode json --session-id <uuid> …
→ transcript mirrored to ~/.pi-runner/transcripts/<cwd>/<uuid>.jsonl
→ container watches it (PI_TRANSCRIPTS_DIR mount) → SSE → viewer
```
- The spawn stamps `harness` into the conversation metadata sidecar, so a
**resume with no model picked continues on the same harness** (backend
fallback); picking a chip from the other harness switches mid-conversation.
- The runner accepts the **same flag shape as `claude`**
(`-p --session-id/--resume --model`), so the sidecar treats both harnesses
identically — pidfiles, launch probe, SIGINT interrupt, `reused` idempotency
all work unchanged.
- **Transcript compatibility is the design**: the runner translates pi's
`message_end` events (user / assistant / toolResult) into Claude-Code-schema
JSONL, so the viewer's parser, cost-by-tool, SSE streaming, DONE detection and
lifecycle checks need no pi-specific code. Adding a third harness = another
runner that writes the same schema.
- **Built-in tool calls are remapped to Claude's shape** (`mapToolCall` in
`cli.mjs`): pi's lowercase `read/write/edit/bash/grep/find/ls` with
`path`/`edits[]` become `Read/Write/Edit|MultiEdit/Bash/Grep/Glob/LS` with
`file_path`/`old_string`/`new_string` (bash `timeout` s→ms). That's what
makes the viewer's cost-by-tool buckets, per-extension sub-breakdowns, auto
project/service tags, bash cards and Read image previews light up for pi
sessions. Unknown (extension) tools pass through and render as generic cards.
- **Real cost, not estimates**: each assistant record carries pi's own
per-message provider cost as `costUSD` (omitted when pi reports none). The
parser (`backend/conversations.py`) prefers it over its price table — so
OpenRouter runs show the billed amount and free local EVOX2 runs cost $0
(bare qwen ids also price at 0 in the fallback table).
## Model → provider routing
| model id | provider |
|---|---|
| `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 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
`extensions/folder-permissions.ts` blocks tool calls outside the session's
scope (`PI_PERM_SCOPE`, default = the run's cwd): `write`/`edit` only inside
the scope, reads inside `PI_PERM_READ` roots (default `$HOME`) + system
prefixes, `bash` commands may not reference paths outside the readable roots,
and a deny-always list protects `.env`/credentials/`.ssh` from writes. This is
*stricter* than the claude harness's `bypassPermissions` — pi itself has no
permission system, so the extension is the enforcement point.
## pi session state
pi sessions persist under `~/.pi-runner/sessions/` keyed by the **same UUID**
the viewer uses (`--session-dir` + `--session-id`), so resume is stateless:
the same id simply reopens the session. Skills load from `~/.claude/skills`
via `~/.pi/agent/settings.json`; pi reads the repo `CLAUDE.md` natively.
## Files
- `cli.mjs` — the runner (arg parsing, EVOX2 wake, pi spawn, transcript writer).
- `extensions/folder-permissions.ts` — the permission gate.
- Env knobs: `PI_BIN`, `PI_RUNNER_ROOT`, `RUNNER_DEFAULT_MODEL`,
`RUNNER_PERM_EXT`, `RUNNER_ENV_FILE`, `EVOX2_URL`/`EVOX2_MAC`/`EVOX2_BCAST`,
and on the sidecar side `RUNNER_BIN`, `SIDECAR_PI_MODEL`.
## Setup (already done on this host)
```bash
cd services/ai-agent/runner && npm install --ignore-scripts
# ~/.pi/agent/models.json — evox2 provider (LM Studio token inside)
# ~/.pi/agent/settings.json — {"skills": ["~/.claude/skills"]}
# OPENROUTER_API_KEY — in ~/homelab/.env.claude
```