#!/usr/bin/env bun /** * Static site generator for the auto-model-router GitHub Pages site. * * Dependency-free by design: content is authored as HTML in this file and the * only dynamic input is `site/data/benchmarks.json`, which the head-to-head * suite tables render from and which `tools/export-benchmarks.ts` regenerates * from a live ledger at release time. Emits `site/dist/`, ready for * `actions/upload-pages-artifact`. * * Run by hand: `bun tools/build-site.ts` (then open site/dist/index.html). */ import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; const ROOT = resolve(import.meta.dir, ".."); const SITE = join(ROOT, "site"); const DIST = join(SITE, "dist"); const REPO = "https://github.com/drewappling/auto-model-router"; // --------------------------------------------------------------------------- // Benchmark data // --------------------------------------------------------------------------- interface SuiteTable { title: string; note: string; columns: string[]; rows: string[][]; winnerCol?: number; } interface LedgerSnapshot { generatedAt: string; windowDays: number | null; requests: number; spendAllTimeUsd: number; spend7dUsd: number; perTurnUsd: number; escalationRatePct: number; perModel: { slug: string; requests: number; sharePct: number }[]; } interface Benchmarks { generatedAt: string; baseline: string; headline: { coreCostMultiple: string; coreCostMultipleLabel: string; realWorldMultiple: string; realWorldSavedPct: string; }; suites: { core: SuiteTable; ladder: SuiteTable; routed: SuiteTable; realWorld: SuiteTable }; ledgerSnapshot: LedgerSnapshot | null; } const bench = JSON.parse(readFileSync(join(SITE, "data", "benchmarks.json"), "utf8")) as Benchmarks; // --------------------------------------------------------------------------- // HTML helpers // --------------------------------------------------------------------------- function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function suiteTable(t: SuiteTable): string { const head = t.columns.map((c) => `
${esc(t.note)}
`; } function ledgerPanel(s: LedgerSnapshot | null): string { if (s === null) { return `No live ledger snapshot is bundled with this build. Maintainers regenerate one with bun tools/export-benchmarks.ts against a real install before a release.
${esc(m.slug)}Generated ${esc(s.generatedAt)} from a real install's ledger (${window}).
| Model | Requests | Spend share |
|---|
A local, keyless model router for Oh My Pi. One OpenAI-compatible provider that picks a concrete OpenRouter model per turn from measured price and estimated task complexity \u2014 including mid-conversation.
OpenRouter has openrouter/auto and openrouter/pareto-code. Both are opaque, server-side, and \u2014 per Pareto's own docs \u2014 "you can't directly cap cost or latency per request." This router does the things a prompt classifier structurally cannot:
OpenRouter sees a prompt. We see omp's tool array, tool-result depth, and whether the previous tool call failed. Most agent turns are mechanical post-tool-result continuations \u2014 the largest cost lever in agent traffic, and invisible upstream.
Per-turn, per-conversation, and rolling-24h caps, checked against a cold-cache forecast before dispatch, with forced downgrade at the ceiling.
Hold the first N tokens; on a malformed tool call, refusal, empty completion, or repeated tool call, abort and re-dispatch upward. omp never observes the failure.
Switching models forfeits the warm prompt cache. The decision is arithmetic, not vibes: expected saving must beat the forfeited cache-read discount by a configured margin.
Per-model escalation and error rates from your traffic demote cheap-but-flaky models automatically.
Every decision \u2014 candidates, rejections, forecasts, reasons \u2014 is persisted and replayable via auto-model-router explain.
auto-model-router runs embedded inside the omp process as an extension \u2014 no separate server, no orphaned process. It binds a free OS-assigned port and lives and dies with the omp session. For non-omp harnesses (Hermes, Claude, any OpenAI-compatible client), run it standalone with auto-model-router serve --port <n>.
No separate Bun install is needed for the embedded path. The standalone serve binary bundles Bun.
npm install -g auto-model-router
Then add the shipped extensions to omp's ~/.omp/agent/config.yml ($PI_CODING_AGENT_DIR/config.yml when that env var relocates the agent dir):
# ~/.omp/agent/config.yml
extensions:
- auto-model-router/omp-extension/router-embed.ts
- auto-model-router/omp-extension/router-toast.ts # optional: chosen-model toasts
- auto-model-router/omp-extension/router-configure.ts # optional: /router command
bun tools/install.ts
It wires the extensions into omp's ~/.omp/agent/config.yml, backing up the previous file first. It is idempotent. Use --no-toast --no-configure for only the required embed extension.
This repo doubles as its own marketplace. Add it as a source, then install:
omp plugin marketplace add drewappling/auto-model-router
omp plugin install auto-model-router@auto-model-router
Or in the TUI: /marketplace add drewappling/auto-model-router then /marketplace install auto-model-router@auto-model-router.
pi install npm:auto-model-router
# or from git:
pi install git:github.com/drewappling/auto-model-router
There is exactly one OpenRouter key on the machine, owned by omp. Once you have run /login openrouter inside omp, auto-model-router borrows that key with no config and no second copy to rotate or leak. Alternatively set OPENROUTER_API_KEY in the environment, or openrouter.apiKey in config.yml.
The catalog and the config command work keyless; only dispatch needs a key.
The router never ships a hand-curated model list. When an OpenRouter key is configured it fetches the key-scoped catalog (GET /models/user) \u2014 the exact set of models that key is entitled to under your account's active guardrails, provider preferences, and data policies \u2014 and routes only within it. Keyless, it falls back to the public catalog for pricing and capability discovery, but dispatch still needs a key.
This means your OpenRouter guardrails \u2014 model and provider allowlists, budget limits, Zero-Data-Retention and privacy rules \u2014 are the router's outer boundary: a model your key cannot reach is never a routing candidate. The catalog is refetched in the background every few minutes, so tightening or relaxing a guardrail is picked up without a restart.
Narrow guardrails still route. If a guardrail shrinks the eligible set so far that a complexity tier's quality floor admits nothing, adaptiveTierFloors (on by default) relaxes that tier's economic envelope to the best available models rather than leaving it empty \u2014 so the router keeps working on a tightly restricted key instead of stalling on the cheapest tier.
After installing, restart the omp session (extensions load at session start), then run /model and pick auto-model-router/auto.
Note on updates. The embedded router is long-lived per omp session and reads its config and code at boot. Config changes to hot-reloadable knobs apply live; changes to the listening socket, the OpenRouter client, or the agentdox bridge require a session restart.
auto-model-router serve --port 8788
Register it as a plain OpenAI-compatible provider pointing at http://127.0.0.1:8788/v1. No API key is enforced unless you set server.apiKey.
Every routing lever lives in $AUTO_MODEL_ROUTER_HOME/config.yml. See the configuration reference for the knobs and their shipped defaults.
auto-model-router is configured through $AUTO_MODEL_ROUTER_HOME/config.yml (defaults to ~/.auto-model-router/config.yml). The file is a deep-partial overlay on the built-in defaults: set only the keys you want to change. Most per-turn knobs hot-reload \u2014 edits apply on the next turn with no restart. The listening socket (server.*), the OpenRouter client (openrouter.*), and the agentdox bridge (context.*) are captured at boot and need a session restart.
All values below are the shipped defaults.
/models/user). Default: empty \u2014 borrowed from omp's credential store, or OPENROUTER_API_KEY.title is the display name; referer is the identity requests are grouped by. Default: auto-model-router and the project URL.Each complexity tier sets a quality floor and a price ceiling. A model priced above a tier's ceiling is excluded before ranking; within the tier, score = (quality/100) ^ qualityExponent / effectiveUsd picks the winner. hard has no ceiling \u2014 quality is the point of the top tier.
hard, which has nowhere to escalate to.downgrade or fail at the ceiling. Default: downgrade.auto-model-router stats # spend and per-model distribution
auto-model-router explain # candidates, rejections, forecasts for the last turn
auto-model-router models # the eligible catalog per tier
The full type surface and every field's doc-comment live in src/config/types.ts.
Measured against Claude Opus 5 on Anthropic first-party. Each task is a real omp session working in a pristine git workspace from a written spec; hidden tests are copied in only after the agent exits, so they cannot be read or edited. Every task is verified to fail an untouched workspace and to pass a reference solution. Both arms are metered from omp's own event stream under an identical tool surface. The router arm routes freely \u2014 nothing pinned.
Data generated ${esc(bench.generatedAt)} \u00b7 baseline ${esc(bench.baseline)}
These are small, self-contained tasks of one to three files. On the core suite both engines solved everything, so it measures cost at equal correctness rather than capability; the ladder is where capability separates. The cost multiple varied between 14\u00d7 and 32\u00d7 across runs depending on which task the baseline stalled on \u2014 treat "well over an order of magnitude" as the claim, not a specific figure. Full harness, tasks, and raw per-turn data are in docs/routing-benchmark-findings.md.