#!/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(c)}`).join(""); const body = t.rows .map((row) => { const cells = row .map((cell, i) => { const win = t.winnerCol !== undefined && i === t.winnerCol && i > 0; return `${esc(cell)}`; }) .join(""); return `${cells}`; }) .join("\n"); return `

${esc(t.title)}

${head} ${body}

${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.

`; } const window = s.windowDays === null ? "all time" : `last ${s.windowDays} days`; const rows = s.perModel .map((m) => `${esc(m.slug)}${m.requests}${m.sharePct.toFixed(1)}%`) .join("\n"); return `

Generated ${esc(s.generatedAt)} from a real install's ledger (${window}).

${s.requests.toLocaleString()}
billed turns
$${s.perTurnUsd.toFixed(4)}
per turn
$${s.spend7dUsd.toFixed(2)}
spend, 7 days
${s.escalationRatePct.toFixed(1)}%
escalation rate
${rows}
ModelRequestsSpend share
`; } // --------------------------------------------------------------------------- // Layout // --------------------------------------------------------------------------- interface Page { slug: string; // "" for index title: string; nav: string; body: string; } const NAV: { href: string; label: string; key: string }[] = [ { href: "index.html", label: "Overview", key: "home" }, { href: "install.html", label: "Install", key: "install" }, { href: "config.html", label: "Configuration", key: "config" }, { href: "benchmarks.html", label: "Benchmarks", key: "benchmarks" }, ]; function layout(p: Page): string { const nav = NAV.map( (n) => `${esc(n.label)}`, ).join("\n "); return ` ${esc(p.title)}
${p.body}
`; } // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- const indexBody = `

The right model for every turn

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.

${esc(bench.headline.coreCostMultiple)}
${esc(bench.headline.coreCostMultipleLabel)}
${esc(bench.headline.realWorldMultiple)}
cheaper on a real week of traffic
${esc(bench.headline.realWorldSavedPct)}
of spend saved

Why this exists when OpenRouter already ships routers

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:

Agent-loop awareness

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.

Budget enforcement

Per-turn, per-conversation, and rolling-24h caps, checked against a cold-cache forecast before dispatch, with forced downgrade at the ceiling.

Mid-stream escalation

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.

Cache-aware hysteresis

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.

Closed-loop trust

Per-model escalation and error rates from your traffic demote cheap-but-flaky models automatically.

Explainability

Every decision \u2014 candidates, rejections, forecasts, reasons \u2014 is persisted and replayable via auto-model-router explain.

How it runs

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>.

Install it →

`; const installBody = `

Installing

No separate Bun install is needed for the embedded path. The standalone serve binary bundles Bun.

Via npm (recommended)

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

From the repo (cross-platform installer)

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.

From the marketplace

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.

As a Pi package

pi install npm:auto-model-router
# or from git:
pi install git:github.com/drewappling/auto-model-router

Setup \u2014 the OpenRouter key

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.

Available models & guardrails

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.

Activating it

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.

Standalone (Hermes / any OpenAI-compatible client)

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.

Configuring behaviour

Every routing lever lives in $AUTO_MODEL_ROUTER_HOME/config.yml. See the configuration reference for the knobs and their shipped defaults.

`; const configBody = `

Configuration reference

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.

openrouter \u2014 upstream & attribution

openrouter.apiKey
OpenRouter key. The router routes only within the models this key is entitled to under your OpenRouter guardrails (fetched via /models/user). Default: empty \u2014 borrowed from omp's credential store, or OPENROUTER_API_KEY.
openrouter.title / openrouter.referer
App attribution for OpenRouter's Activity/Apps ranking. title is the display name; referer is the identity requests are grouped by. Default: auto-model-router and the project URL.
openrouter.timeoutMs
Per-request timeout. Agent turns are long. Default: 600000 (10 min).

tiers \u2014 the complexity ladder

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.

tiers.trivial
minQuality 0, maxInputPerMtok $0.30, qualityExponent 0 (cheapest above the floor).
tiers.simple
minQuality 40, maxInputPerMtok $1.50, qualityExponent 0.
tiers.moderate
minQuality 60, maxInputPerMtok $4.00, qualityExponent 1.
tiers.hard
minQuality 72, no price ceiling, qualityExponent 3.
tiers.<tier>.capabilityFloorUsd
Optional. Pick the highest-quality candidate whose cold-cache cost fits this cap, ignoring quality-per-dollar. Buys quality with money deliberately. Default: unset.
tiers.<tier>.pin
Force a specific slug set for the tier. Default: none.

filters \u2014 the eligible catalog

filters.includeFree
Include $0 models. Default: false \u2014 free models are rate-limited enough that retries cost more than they save.
filters.requireToolSupport
Default: true.
filters.minTrust / minTrustSamples
Demote models whose measured reliability falls below the floor once enough samples exist. Default: 0.7 over 12 samples.
filters.contextHeadroom
Require a context window this multiple of the estimated prompt. Default: 1.25.
filters.latencyWeight
Inflate a model's effective cost by expected wait (TTFT + completion time). Default: 0 (off) \u2014 opt in after establishing a baseline.
filters.maxExpectedWaitMs
Absolute expected-wait ceiling: a hard drop for models proven slower than this (≥ latencyMinSamples), regardless of price \u2014 the soft penalty above is multiplicative and capped, so it cannot demote a slow-but-cheap model. New models keep their cold-start turns. Default: unset (off).

escalation \u2014 mid-stream recovery

escalation.enabled
Default: true.
escalation.probeTokens
Hold this many tokens before committing, to catch a bad start. Default: 48.
escalation.maxAttempts
Original try plus retries. Each retry beyond the first can abandon generated tokens. Default: 3.
escalation.triggers
malformed_tool_args, refusal, empty_completion, repeat_tool_call, missing_expected_tool_call.
escalation.probeTiers
trivial, simple, moderate \u2014 never hard, which has nowhere to escalate to.

hysteresis \u2014 cache-aware stickiness

hysteresis.holdTurns / holdTurnsAfterEscalation
Hold the current tier for N turns to protect the warm cache. Default: 2, and 4 after an escalation.
hysteresis.switchMargin
Expected saving must beat the forfeited cache discount by this factor to switch. Default: 1.3.
hysteresis.maxDowngradePerTurn
Step tiers down at most this fast. Default: 1.
hysteresis.breakHoldOnMechanical
Let a mechanical tool-result continuation break a hold that sits above the fresh classification. Default: false.

budget \u2014 spend caps

budget.perTurnUsd / perConversationUsd / rolling24hUsd
Optional ceilings, checked against the cold-cache forecast before dispatch. Default: no caps.
budget.onExceeded
downgrade or fail at the ceiling. Default: downgrade.

context \u2014 agentdox bridge (restart to change)

context.enabled
Inject one shared project-context block per conversation. Default: false \u2014 needs a URL and token.
context.baseUrl / token / defaultScope
agentdox endpoint, bearer, and fallback project scope.
context.memoryLimit / docsLimit / sessionLimit / briefChars
Bound what the server selects, so the block is ranked rather than byte-truncated. Default: 8 / 2 / 6 / 12000, inside a 24000-char cap.

compaction \u2014 prompt shrinking

compaction.enabled
Shrink stale, low-value context before dispatch. Default: false \u2014 elision is lossy, never implicit.
compaction.budgetTokens
Fire above this prompt size. Default: 40000.
compaction.floorRatio
Compact to this fraction of the budget. Below 1 overshoots and holds the plan (cache-friendly); 1 re-tightens every turn. Default: 1; 0.75 recommended once you have watched your ledger.

Inspecting decisions

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.

`; const benchmarksBody = `

Benchmarks

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)}

${suiteTable(bench.suites.core)} ${suiteTable(bench.suites.ladder)} ${suiteTable(bench.suites.routed)} ${suiteTable(bench.suites.realWorld)}

Live ledger snapshot

${ledgerPanel(bench.ledgerSnapshot)}

Scope & honesty

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.

`; const PAGES: Page[] = [ { slug: "index", title: "auto-model-router \u2014 the right model for every turn", nav: "home", body: indexBody }, { slug: "install", title: "Install \u2014 auto-model-router", nav: "install", body: installBody }, { slug: "config", title: "Configuration \u2014 auto-model-router", nav: "config", body: configBody }, { slug: "benchmarks", title: "Benchmarks \u2014 auto-model-router", nav: "benchmarks", body: benchmarksBody }, ]; // --------------------------------------------------------------------------- // Emit // --------------------------------------------------------------------------- function main(): void { rmSync(DIST, { recursive: true, force: true }); mkdirSync(DIST, { recursive: true }); for (const p of PAGES) { writeFileSync(join(DIST, `${p.slug}.html`), layout(p), "utf8"); } cpSync(join(SITE, "assets"), join(DIST, "assets"), { recursive: true }); // .nojekyll: the artifact is already built HTML; skip GitHub's Jekyll pass. writeFileSync(join(DIST, ".nojekyll"), "", "utf8"); console.log(`built ${PAGES.length} pages \u2192 ${DIST}`); } main();