auto-model-router0.38.0
Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
auto-model-router
A local model router for Oh My Pi. It presents itself as one keyless OpenAI-compatible provider, then picks a concrete model per turn — from OpenRouter's catalog, and from Ollama Cloud when that is enabled too — based on measured price and estimated task complexity, including mid-conversation, when a session shifts from mechanical tool-loop churn to genuine reasoning work. With both providers on, every turn ranks the candidates of both together and fails over across them.
auto-model-router runs embedded inside the omp process (as an omp extension) — 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 as
a standalone process with auto-model-router serve --port <n> — the same core,
on a fixed port, owned by you. See Hermes below.
Why this exists when OpenRouter already ships routers
OpenRouter has openrouter/auto (market-spend classifier) and
openrouter/pareto-code (Artificial Analysis coding percentile → cheapest in
tier). Both are opaque, server-side, and — per Pareto's own docs — "you can't
directly cap cost or latency per request."
This router exists for the things a prompt classifier structurally cannot do:
| Lever | Why it needs to be local |
|---|---|
| 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 — 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 — candidates, rejections, forecasts, reasons — is persisted and replayable via auto-model-router explain. |
Measured against Claude Opus 5
Five benchmark runs, 88 graded task runs, 2026-08-29. 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 by it;
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, run under an
identical tool surface, and are checked per turn against their expected provider.
The router arm routes freely — nothing pinned. The baseline is claude-opus-5
on Anthropic first-party.
Core suite — 10 coding tasks × 3 trials
| auto-model-router | Claude Opus 5 | |
|---|---|---|
| Tasks solved | 30 / 30 | 30 / 30 |
| Total cost | $0.63 | $16.61 |
| Cost per solved task | $0.0209 | $0.5538 |
| Turns to finish | 278 | 303 |
| Tool calls | 265 | 337 |
| Wall clock | 2 057 s | 3 185 s |
| Median time to first token | 5 776 ms | 1 490 ms |
26.5× cheaper at identical correctness — and in fewer turns, fewer tool calls, and 19 minutes less wall clock. The saving is not bought by grinding out extra turns. The one regression is time to first token: a routed turn pays for classification and dispatch before anything streams back.
Per task the ratio ranges from 9× to 264×. The widest gaps are tasks where the
single-model baseline entered long tool loops — semver and queue-order cost
it $2.99 each across three trials against a $1.32 median, 36% of its entire bill.
Difficulty ladder — 7 rungs, run twice
A second suite of deliberately escalating difficulty, ending in npm semver range semantics and a minimal diff with a specified tie-break.
| auto-model-router | Claude Opus 5 | |
|---|---|---|
| Run 1 | 5 / 7 · $0.30 | 5 / 7 · $6.25 |
| Run 2 | 5 / 7 · $0.46 | 6 / 7 · $6.60 |
At the top of the ladder the engines separate: they fail different rungs, and on the second run the single-model baseline finished one more. Both arms timed out on the semver rung at the 10-minute cap.
What it routed to
Across 464 routed turns in all five runs:
| Model | Turns | Input price | Role |
|---|---|---|---|
z-ai/glm-5.3-flash |
389 (84%) | $0.07 / MTok | default |
google/gemini-3.7-flash |
56 (12%) | $0.75 / MTok | escalation target |
x-ai/grok-4.6 |
18 (4%) | $2.00 / MTok | escalation target |
Tier escalation converts to a costlier model roughly one-for-one: on the
ladder, the count of turns classified hard matched the count served by
something other than the default (6/6, 4/4, 3/3, 5/5, 7/7, 1/1 across rungs and
runs). The escalation target is chosen live from trust and latency history, so
it differs between runs on the same catalog — run 1 stepped up to
gemini-3.7-flash, run 2 to grok-4.6.
Escalation stays inside the cheaper half of the catalog. A model priced above a
tier's maxInputPerMtok is excluded before ranking, and at hard the
(quality/100)^qualityExponent ÷ expected cost score favours cheaper models that
score nearly as well. If your workload needs a frontier model on hard turns,
raise the tier price ceiling and qualityExponent — measured thresholds are in
docs/routing-benchmark-findings.md.
Real-world — a week on the live ledger
The suites above are small and clean. To measure the economics on actual usage we replayed a week of real omp traffic from the router's own ledger — 6 918 billed turns across 299 conversations, 7 days, 410:1 input-to-output, 68% cache hit — and repriced the identical token stream against a single Opus 5 model with its own cache namespace.
| auto-model-router | Claude Opus 5 (single-model) | |
|---|---|---|
| Spend over the week | $61.69 | $921.20 |
| Per turn | $0.0089 | $0.133 |
| Extrapolated / month | $263 | $3 932 |
≈15× cheaper, ~93% saved — a four-figure monthly bill becomes a three-figure one. This baseline is deliberately conservative: one cache namespace, with each conversation's cache replayed on the real turn gaps. A naive like-for-like repricing at Opus rates reports ~31×, but on a single model the replayed context is cache reads at $0.50/MTok, so ≈15× is the number we stand behind. Unlike the core suite, sustained work on a large codebase is dominated by the conversation resent each turn rather than per-token price — exactly where a single frontier model gets expensive and routing's per-turn cache awareness pays off.
Scope
These are small, self-contained tasks of one to three files, solved in under 25 turns. 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× and 32× across runs depending on which task the baseline stalled on — treat "well over an order of magnitude" as the claim, not a specific figure.
Harness, tasks and raw per-turn data:
docs/routing-benchmark-findings.md.
Architecture
graph LR
omp[omp process] -->|OpenAI chat completions| wire[wire/openai]
wire -->|NormRequest| router[router]
orcat[OpenRouter /models] --> catalog[catalog<br/>one merged snapshot]
olcat[Ollama /api/tags + prices<br/>optional] --> catalog
catalog --> router
cost[cost<br/>forecast + ledger] --> router
router -->|Decision| guard[escalation guard]
guard -->|rendered body| up[upstream/multi<br/>by slug prefix]
up --> or[openrouter]
up --> ol[ollama<br/>ollama/… slugs]
or -->|UpstreamChunk| guard
ol -->|UpstreamChunk| guard
guard -->|commit, fail over, or retry upward| wire
guard -->|usage + reported cost| cost
The router runs in-process inside omp via the router-embed extension. The
core never parses a wire format. A front end produces a NormRequest and
consumes UpstreamChunks, so a pi-native front end can be added later
without touching routing.
Module map
| Path | Responsibility |
|---|---|
src/catalog/ |
Fetch and normalize OpenRouter /api/v1/models: pricing, capability flags, Artificial Analysis quality indices. SQLite-cached with TTL. ollama-catalog.ts builds Ollama Cloud models from /api/tags, a shipped price table and OpenRouter twins; composite.ts merges the two into one snapshot. |
src/cost/ |
Cost forecasting per candidate; reconciliation against OpenRouter's authoritative usage.cost; the spend ledger; per-model trust; rolling blended rate; report.ts usage analytics. |
src/tokens/ |
Token estimation with no tokenizer dependency, self-calibrating from observed prompt_tokens per tokenizer family. |
src/wire/ |
Protocol boundary. wire/openai/ implements chat completions in and SSE out. |
src/router/ |
Feature extraction, complexity classification, candidate filtering and scoring, hysteresis, cache-breakpoint placement, budget guard, probe planning. |
src/upstream/ |
Transports: OpenRouter (streaming dispatch, session_id stickiness, error classification, fallback arrays) and Ollama Cloud (body rewrite for its compatibility layer, quota/rate-limit breaker); multi.ts dispatches by slug prefix. |
src/config/ |
Configuration loading, schema validation, and the built-in defaults. |
src/cli/ |
serve, stats, report, models, explain, config commands. |
omp-extension/ |
The omp extensions: router-embed.ts, router-toast.ts, router-configure.ts (/router config, report, status). |
Two cost numbers, never conflated
- Predicted — our arithmetic over the catalog, computed before dispatch.
Drives routing and budget guards. Must model
pricing.overridestiers, or long conversations are underestimated by ~50% exactly when it matters. - Reported —
usage.costfrom OpenRouter, authoritative after the fact. Drives the ledger,stats, and prediction-error calibration.
Installing
No separate Bun install is needed for the embedded path. The standalone
serve binary (npm install -g auto-model-router) bundles Bun.
Two ways to get the router into omp. The npm package is the modern path —
it installs the auto-model-router binary and wires the omp extensions; the
repo-local installer is for developing against the source.
Via npm (installs the auto-model-router binary)
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 config, report, status
- auto-model-router/omp-extension/router-digest.ts # optional: cheap-model digest of large tool results
From the repo (cross-platform installer)
bun tools/install.ts
It wires the auto-model-router extensions into omp's ~/.omp/agent/config.yml
($PI_CODING_AGENT_DIR/config.yml when that env var relocates the agent dir),
backing up the previous file first. It is idempotent — re-running is a no-op.
Options:
bun tools/install.ts --no-toast --no-configure # only the required embed extension
The installer adds:
router-embed.ts— required; runs the router in-process.router-toast.ts— optional; per-turn toasts naming the model and why it was chosen.router-configure.ts— optional; the/routercommand (configure, usage reports, status).router-digest.ts— optional; condenses large tool results with a cheap model before an expensive one reads them (needsdigest.enabled).
Or add the paths by hand to omp's ~/.omp/agent/config.yml:
# ~/.omp/agent/config.yml
extensions:
- /path/to/auto-model-router/omp-extension/router-embed.ts
- /path/to/auto-model-router/omp-extension/router-toast.ts # optional: chosen-model toasts
- /path/to/auto-model-router/omp-extension/router-configure.ts # optional: /router config, report, status
- /path/to/auto-model-router/omp-extension/router-digest.ts # optional: cheap-model digest of large tool results
Then restart the omp session (extensions load at session start).
or install it from the marketplace (see below). The plugin declares all three
extensions (router-embed, router-toast, router-configure), so installing
it wires the router in without editing config.yml by hand.
Install from the marketplace
This repo doubles as its own marketplace: it ships a catalog at
.omp-plugin/marketplace.json listing the auto-model-router plugin. Add the repo as
a marketplace source, then install the plugin:
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
/marketplace install auto-model-router@auto-model-router
After installing, restart the omp session (extensions load at session start),
then /model and pick auto-model-router/auto.
Install from the Pi package marketplace
The repo is also a Pi package (see the pi manifest and pi-package keyword
in package.json), so it can be installed with the Pi CLI and listed on
pi.dev/packages:
pi install npm:auto-model-router
or from git:
pi install git:github.com/drewappling/auto-model-router
Releasing
Cut releases with npm version (or bun run release <patch|minor|major>), not a
bare npm publish:
npm version patch && git push --follow-tags # or: bun run release patch
npm version runs the version lifecycle script
(tools/sync-marketplace-version.ts), which rewrites the Git-marketplace
catalog (.omp-plugin/marketplace.json) to the new version and stages it into
the version commit — so the npm package and the marketplace catalog can never
drift. Pushing the vX.Y.Z tag triggers the release workflow (npm publish,
which auto-indexes on pi.dev/packages, plus a GitHub Release). A bare
npm publish skips both the catalog sync and the tag, so avoid it.
Hermes
Install the router globally (puts the serve binary on PATH) and
install the native plugin, then point Hermes at it:
1. Install the router binary:
npm install -g auto-model-router
2. Install the Hermes plugin. Copy hermes-plugin/ to
$HERMES_HOME/plugins/model-providers/auto-model-router/ (where
HERMES_HOME is C:\Users\<you>\AppData\Local\hermes on Windows,
~/.hermes on macOS/Linux):
mkdir -p "$HERMES_HOME/plugins/model-providers"
cp -r hermes-plugin/ "$HERMES_HOME/plugins/model-providers/auto-model-router/"
3. Surface the provider in Hermes's picker. Hermes only lists providers
that have a credential. The router itself is keyless (it resolves its own
OpenRouter key), but to make Hermes show it as selectable, add a marker value
to $HERMES_HOME/.env:
echo "AUTO_MODEL_ROUTER_API_KEY=local" >> "$HERMES_HOME/.env"
4. Restart Hermes. On load, the plugin spawns the router (auto-model-router serve) as a subprocess on port 8788 and registers the provider profile. Select
auto-model-router/auto as the model.
The plugin runs the router against its own config home
($HERMES_HOME/auto-model-router/), separate from omp's
~/.auto-model-router/, so the two harnesses never share a ledger or
conversation state and don't leak routing toasts into each other's UIs.
The router serves GET /v1/models (returning the auto, auto-cheap,
auto-max profiles) and POST /v1/chat/completions, which Hermes's custom
endpoint discovery verifies. The router's own OpenRouter key resolution
(config → env → omp auth store) applies — Hermes does not need its own
OpenRouter key.
Standalone alternative (no plugin): run the router yourself, then add a custom provider:
auto-model-router serve --port 8788
# $HERMES_HOME/config.yaml
providers:
auto-model-router:
base_url: http://127.0.0.1:8788/v1
api_key: local
default_model: auto
Native features (Hermes plugin API). The provider plugin above only
registers the model provider; Hermes never calls register(ctx) on
provider plugins, so the features that need hooks live in a second,
standalone plugin:
cp -r hermes-plugin/native "$HERMES_HOME/plugins/auto-model-router"
hermes plugins enable auto-model-router
It adds, through Hermes middleware and hooks:
- Session identity —
X-Omp-SessionandX-Omp-Subagenton every router request (a session that reported a parent session is a subagent), so per-session reports,/router why, feedback and the router'sserver.subagentProfilework as in omp.X-Omp-Harnessishermes(orOMP_HARNESS_ID). - Tool-result digest — large
read_file,search_filesandterminalresults go to/v1/router/digestand the model gets the digest (seedigest; Hermes tool names are mapped bydigest.toolAliases). Off unlessdigest.enabled. /router—report [days] [--all],summary,status,why,good/bad [note],pin <model|off>,tier <tier|off> [turns], as text.
Point Hermes's side jobs at the cheap profile so they cost what omp's do:
# $HERMES_HOME/config.yaml
auxiliary:
vision: { provider: auto-model-router, model: auto-cheap }
compression: { provider: auto-model-router, model: auto-cheap }
Not available in Hermes: a per-turn routing toast (its plugin API has no
user-visible notice channel; use /router why), the automatic daily summary
(/router summary on demand), and the harness-side model switch.
Codex CLI
Codex (0.150 and later) speaks only the Responses API, which the router
serves at POST /v1/responses: the body is translated to the chat shape
the router routes on (instructions → system, input items → messages,
function calls and outputs → tool calls and tool messages) and the upstream
stream is rendered back as Responses events. Run the router
(auto-model-router serve --port 8788) and add a provider:
# ~/.codex/config.toml
model = "auto"
model_provider = "auto-model-router"
[model_providers.auto-model-router]
name = "auto-model-router"
base_url = "http://127.0.0.1:8788/v1"
env_key = "AUTO_MODEL_ROUTER_API_KEY" # any value; the router is keyless
wire_api = "responses"
http_headers = { "X-Omp-Harness" = "codex" }
Verified live with codex 0.153, text and tool-call turns: the captured
request is test/fixtures/harness/codex-responses.json. Stateless only —
Codex sends store: false and the full input each turn; previous_response_id
is rejected. Reasoning summaries and encrypted reasoning are not produced.
Codex's thread id (sent in the body) becomes the session id and its agent
name marks subagents, so per-session reports, feedback over the HTTP API and
the subagent profile work without a plugin. No hooks: there is no toast,
digest or /router.
Aider
export OPENAI_API_BASE=http://127.0.0.1:8788/v1
export OPENAI_API_KEY=local
aider --model openai/auto
Verified live with aider 0.86 (captured request:
test/fixtures/harness/aider.json). Aider sends no tool calls, so every turn
classifies on its text alone. It sends no custom headers by default; a model
settings file in the project adds the harness id (verified live):
# .aider.model.settings.yml
- name: openai/auto
extra_params:
extra_headers:
X-Omp-Harness: aider
No session id or hooks.
Cline CLI
cline auth -p openai -b http://127.0.0.1:8788/v1 -k local -m auto
cline -P openai -m auto "your task"
Verified live with cline 3.0 (captured request:
test/fixtures/harness/cline-cli.json). The CLI sends native tool calls
(read_files, search_codebase, run_commands, fetch_web_content, …),
all in digest.toolAliases. No session id or hooks.
auto-model-router connect writes this itself, and writes one thing the
auth command cannot: Cline's provider store
(~/.cline/data/settings/providers.json) has a settings.headers map with no
CLI flag behind it, so a file written by hand is what finally gives Cline a
harness id. Verified live on cline 3.0.61 by pointing it at a recording
server — test/fixtures/harness/cline-cli-connected.json is that capture, with
X-Omp-Harness: cline on it. That one file also serves the VS Code
extension, which reads the same store since its settings migration, so the
extension needs no separate recipe (and neither does any editor that hosts it).
Kilo Code CLI
Kilo's CLI is built on OpenCode, so its config is OpenCode's with a different file name:
// kilo.json in the project (or ~/.config/kilo/kilo.json)
{
"provider": {
"auto-model-router": {
"npm": "@ai-sdk/openai-compatible",
"name": "auto-model-router",
"options": { "baseURL": "http://127.0.0.1:8788/v1", "apiKey": "local", "headers": { "X-Omp-Harness": "kilo" } },
"models": { "auto": { "name": "auto" }, "auto-cheap": { "name": "auto-cheap" } }
}
},
"model": "auto-model-router/auto"
}
Verified live with kilo 7.5 (captured request:
test/fixtures/harness/kilo.json); tool names match OpenCode's. The
OpenCode plugin was not picked up from .kilo/plugin, .opencode/plugin or
the config's plugin list in this test, so Kilo is config-only for now.
Roo Code (VS Code)
Roo's welcome screen has Import Settings; a profile file skips the form:
{
"providerProfiles": {
"currentApiConfigName": "auto-model-router",
"apiConfigs": {
"auto-model-router": {
"apiProvider": "openai",
"openAiBaseUrl": "http://127.0.0.1:8788/v1",
"openAiApiKey": "local",
"openAiModelId": "auto",
"openAiHeaders": { "X-Omp-Harness": "roo" },
"openAiCustomModelInfo": { "maxTokens": 8192, "contextWindow": 400000, "supportsImages": true, "supportsPromptCache": true, "inputPrice": 0, "outputPrice": 0 },
"id": "amr-0001"
}
}
}
}
Verified live with Roo Code 3.54 (captured request:
test/fixtures/harness/roo.json, including a tool round trip): native tool
calls (read_file, search_files, list_files, apply_diff, …), all in
digest.toolAliases, and the harness header through openAiHeaders. Two
cautions: 3.54 announces itself as the last Roo Code release, and its
Architect mode loops on a model that never calls attempt_completion, so
start in Code mode or pin a stronger profile (auto-max) for it. No session
id or hooks: the digest applies only through summarising compaction.
Cline (VS Code)
Nothing extra: the extension reads the same provider store as the CLI
(~/.cline/data/settings/providers.json), so connect has already configured
it — pick the OpenAI Compatible provider and the auto model. By hand, set
the base URL to http://127.0.0.1:8788/v1, any API key, and the model id
auto (or auto-cheap / auto-max). Its tool names are in
digest.toolAliases, and the digest applies only through summarising
compaction.
OpenCode
// ~/.config/opencode/opencode.json
{
"provider": {
"auto-model-router": {
"npm": "@ai-sdk/openai-compatible",
"name": "auto-model-router",
"options": { "baseURL": "http://127.0.0.1:8788/v1", "apiKey": "local", "headers": { "X-Omp-Harness": "opencode" } },
"models": { "auto": { "name": "auto" }, "auto-cheap": { "name": "auto-cheap" }, "auto-max": { "name": "auto-max" } }
}
},
"model": "auto-model-router/auto"
}
Verified live with opencode 1.18 (captured request:
test/fixtures/harness/opencode.json). OpenCode's AI SDK validates every
SSE frame, which is why the router's final summary frame is shaped as a
chunk with no choices. Its tool names (read, grep, glob, bash,
webfetch) match the router's canonical list.
auto-model-router connect writes both halves of this: the provider block
(other providers, MCP servers and $schema untouched) and the plugin below.
The config directory is ~/.config/opencode on every platform, Windows
included — OpenCode uses the XDG layout there rather than %APPDATA%.
Native features (OpenCode plugin API). Copy
opencode-plugin/auto-model-router.ts to ~/.config/opencode/plugin/ (or a
project's .opencode/plugin/); OpenCode loads it on start (plugins/, the
name current docs use, is loaded too — both were confirmed on opencode 1.18).
It adds:
- Session identity —
X-Omp-Session,X-Omp-Harness(opencode, orOMP_HARNESS_ID) andX-Omp-Subagentfor sessions with a parent, through thechat.headershook. - Routing toast — when a session goes idle, its last routed turn's provider, model, tier and cost appear as a TUI toast.
- Tool-result digest — large
read,grep,glob,bashandwebfetchresults go to/v1/router/digestthroughtool.execute.afterand the model gets the digest. Off unlessdigest.enabled.
No /router command (OpenCode commands are markdown files, not plugin
hooks): use auto-model-router report on the terminal, or the router's
HTTP endpoints.
Continue
# ~/.continue/config.yaml
name: auto-model-router
version: 0.0.1
schema: v1
models:
- name: auto-model-router
provider: openai
model: auto
apiBase: http://127.0.0.1:8788/v1
apiKey: local
roles: [chat, edit, apply, summarize]
capabilities: [tool_use, image_input]
requestOptions:
headers:
X-Omp-Harness: continue
connect writes all three profiles as entries like this one, editing the YAML
document rather than re-serialising it, so a hand-written config keeps its
comments, key order and other models; entries are matched by name, so a later
connect replaces them in place. Pick auto-model-router in the model dropdown.
This is Continue's documented assistant schema rather than a shape captured
here — Continue was not run against the router — and config.yaml takes
precedence over the older config.json if you still have one.
Cursor
Cursor has no provider file to write: the OpenAI override is an application
setting in the editor's own state. connect prints the values instead —
Cursor Settings → Models → OpenAI API Key, enable the base-URL override, then
the base URL, the key and auto as a custom model.
Two limits worth knowing before you try. Cursor proxies chat through its own
servers with your key attached, so the base URL has to be reachable from the
internet — a 127.0.0.1 router or a LAN team edition will never be called. And
the override carries no custom header, so those turns arrive with no
X-Omp-Harness id and share the unnamed budget.
Windsurf
Windsurf has no custom base-URL field at all: its bring-your-own-key page takes
first-party provider keys, and the files under ~/.codeium/<channel> are MCP
servers, rules and skills. So the route in is an extension. Windsurf is a VS
Code fork, and Cline reads the provider store connect already wrote
(~/.cline/data/settings/providers.json) — installing the extension is the
whole configuration. Continue works the same way against ~/.continue/config.yaml.
connect says exactly this when it finds a Windsurf install.
The OpenRouter key
omp does not need to be authenticated to OpenRouter. On a routed turn omp
never calls OpenRouter directly: the embed extension registers the
auto-model-router provider with a placeholder bearer (embedded) pointing at
the in-process router, and the router holds the real OpenRouter key and makes
the upstream call. omp only needs to see that the provider "has credentials",
which the placeholder satisfies.
There should be exactly one OpenRouter key on the machine. The router resolves it in this order:
openrouter.apiKeyin$AUTO_MODEL_ROUTER_HOME/config.yml— router-owned, never enters omp's environment. Set it withauto-model-router configor by hand.OPENROUTER_API_KEYin the environment omp launches from (including any.envomp loaded).- omp's own auth store —
~/.omp/agent/agent.db, provideropenrouter, so/login openrouterinside omp is sufficient and nothing needs copying.
Options 1–2 give the router its own key with omp left unauthenticated; option 3
is a zero-config convenience for when you have logged omp in. The store is
opened read-only and never written: omp owns it, including OAuth refresh. An
expired OAuth access token is rejected rather than sent, because refreshing is
omp's job and a stale bearer just burns a turn on a 401. Under
OMP_AUTH_BROKER_URL the local store is not consulted at all, since a broker
replaces it.
The embedded router reports the key source via its in-process GET /health
(config | env | omp-auth-store | none) — never the key itself.
Available models & guardrails
The router never ships a hand-curated model list. With a key configured it
fetches the key-scoped catalog (GET /models/user) — the exact set of
models that key is entitled to under your account's active
OpenRouter guardrails,
provider preferences, and data policies — and routes only within it. Keyless, it
falls back to the public /models for pricing and capability discovery, but
dispatch still needs a key.
Your OpenRouter guardrails — model and provider allowlists, budget limits,
Zero-Data-Retention and privacy rules — are therefore the router's outer
boundary: a model your key cannot reach is never a routing candidate. The
catalog is refetched in the background every catalogRefreshMs (default 5 min),
so tightening or relaxing a guardrail is picked up without a restart. A refresh
that keeps fewer than half the previous models is adopted (your guardrails are
authoritative) but logged at warn and reported as catalog.shrink on
GET /health until the catalog recovers, because a sharp shrink reroutes every
turn onto whatever survived. If a
guardrail narrows the eligible set below a tier's quality floor,
adaptiveTierFloors (on by default) relaxes that tier to the best available
models rather than leaving it empty — see Adaptive tier floors
and Tier rescue below.
How it runs
At session start, the main omp session's router-embed.ts:
- binds a free OS-assigned port (
Bun.serve({ port: 0 })) so several omp sessions never collide on a fixed port; - writes the actual bound port to the shared
$AUTO_MODEL_ROUTER_HOME/embed.port; - registers an
auto-model-routerprovider with omp (auto,auto-cheap,auto-maxvirtual models) pointing athttp://127.0.0.1:$PORT/v1.
Subagents do not bind their own router. They are ephemeral worker processes
whose PIDs get recycled, so a per-process port file is a race. Instead every
subagent registers the same shared provider and routes to the main session's
single router, whose port lives in the one shared embed.port file — one
authoritative writer, no stale per-PID port.
The router lives and dies with the main omp session — no orphan process, no "is the server running?" stopping the omp process frees the port automatically.
Multiple omp sessions, one machine
Each top-level omp session binds its own router on its own ephemeral port, so
they never conflict. The X-Omp-Harness header (from server.harnessId)
scopes budgets, toasts, and optional trust per harness.
Selecting the provider / model
The router registers three virtual models under the auto-model-router provider:
| Profile | Min tier | Max tier | Use |
|---|---|---|---|
auto |
trivial | hard | Default — routes by complexity across the whole range. |
auto-cheap |
trivial | simple | Cost-first — caps at the simple tier. |
auto-max |
moderate | hard | Quality-first — never below moderate. |
Select one in omp via /model and pick auto-model-router/auto (or one of the
others). Or set it as the default for a role in ~/.omp/agent/config.yml:
modelRoles:
default: auto-model-router/auto
The router decides the concrete OpenRouter model per turn; omp only sees the
virtual profile it picked. Every routed response carries
x-auto-model-router-model, x-auto-model-router-tier, x-auto-model-router-cost-usd,
x-auto-model-router-attempts, and x-request-id — the id the turn's ledger rows
are filed under (see Request ids).
Usage reports
The ledger records every dispatch: model decided and served, tier, provider,
tokens (including cached), reported cost, time to first token, total latency,
escalation signal, error, the agentdox context scope the turn carried (the
project it belongs to; NULL for a turn that carried none, and for every row
written before v0.19.0), how many strings redaction removed from the request
(NULL when redaction was off, and for every row written before v0.21.0) and the
request_id the turn answered (indexed; NULL for every row written before
v0.37.0 — see Request ids). Three views aggregate it, all from the same
buildUsageReport in src/cost/report.ts:
/router reportin omp — a fullscreen hub with the/modelslook: views for overview, providers, models, tiers, by day and status in a sidebar, plus a Window selector (24h / 7d / 30d / 90d) and, whenOMP_HARNESS_IDis set, a scope toggle between this harness and all harnesses. ↑/↓ move, Enter applies a window or scope, ←/→ also cycle the window, PgUp/PgDn scroll, r reloads, Esc closes. Headless sessions get the same report as text in the transcript. Falls back to reading the ledger directly if the router is unreachable.auto-model-router report --days 7 [--harness <id>] [--json]on the terminal.auto-model-router export --days 30 [--harness a,b] [--json]: one row per day, harness, model and context scope (dispatches, tokens, spend, escalations, errors) as CSV — thescopecolumn is last, and is empty for a turn that carried none. AlsoGET /v1/router/export?days=&harness=[&format=json];GET /v1/router/spend?sinceMs=&harness=[&scope=]gives spend over a harness set since an instant, narrowed to one context scope whenscopeis given (a project's own bill), andGET /v1/router/feedback?days=&harness=lists verdicts by model and the recent ones with the harness that gave them, andGET /v1/router/decisions?harness=&days=|since=&slug=&tier=&limit=is the decision trail itself, newest first, each turn with its reasons, the classifier's view, forecast against bill, escalation signal, verdicts and itsrequestId(?session=narrows to one omp session, as/router whydoes;?requestId=narrows to the one request a customer quoted, every attempt of it included). These are what a front door such as the team edition reads instead of the ledger file.GET /v1/router/catalog[?policy=<X-Omp-Policy JSON>]— the model catalog as data: every model the router knows, sorted by slug, with provider (openrouter,ollama, a named upstream's id), vendor, context, capabilities, prices in USD per million tokens and quality scores; a model whose upstream cannot serve right now (no key, disabled, in cooldown) is listed too. Withpolicyevery model also carriesadmittedand, when out,reason(not in the allow list,denied by <glob>,pinned to <slug>, the router's own filters, or the upstream's state), judged by the same matcher a turn uses, so the view never drifts from what a turn gets; tiers are per turn and do not take part. A malformed policy is a 400; before the first fetch it answers{ "fetchedAtMs": 0, "models": [] }rather than waiting. A team front door reads it for its model governance views.GET /v1/router/report?days=7&harness=<id>for dashboards (harnessmay be a comma-separated set of ids, for a group).GET /v1/router/summary?harness=<id>— the daily summary as JSON (auto=1applies the once-a-day gate and returnsdue: falsewhen nothing is due).POST /v1/router/prune— appliesledger.retentionDaysnow and answers{ deleted, oldestKeptMs, retentionDays }. See Data governance.
What it shows, for the window:
| Block | Columns |
|---|---|
| totals | spend, dispatches, conversations, $/dispatch, prompt and completion tokens, cache hit rate, model switches, escalations, failovers, errors (aborted separately), subagent dispatches and their share of spend |
| prompt anatomy | mean share of prompt bytes by role (tool results, assistant, user, system), tool schemas beside them, the older half of the conversation, and tool results older than the newest 20 messages — what compaction can reach. Recorded per turn from v0.3.5. |
| providers | per upstream (openrouter, ollama): dispatches, spend, share, cache hit, mean TTFT, tokens/s, escalations, errors |
| models | per served slug (top 12 by spend): the same plus user feedback (+good/-bad from /router good|bad) and the tier mix it was routed for |
| tiers | per tier: dispatches, spend, share, cache hit, mean prompt tokens, escalations |
| by day | UTC calendar days: dispatches, spend, cache hit |
| same traffic on one model | the window's tokens priced on each report.baselines model at list price with the window's cache hit rate, and what share the router saved against it |
Soft-failure spikes. /health (softFailures.spikes), /router status
and the daily summary list any model whose failure rate over the last hour —
probe rejections such as empty_completion or repeat_tool_call that
OpenRouter counts as success, plus attributable transport errors — is at
least 25%, at least twice its own rate over the preceding 7 days, and covers
at least 5 dispatches with 3 failures. This is visibility only: two weeks of
ledger data showed soft failures do not cluster tightly enough for a breaker
to save money (after a burst, the next 15 minutes ran 84–1,577 successes per
13–50 failures), and OpenRouter's provider failover plus the router's own
escalation already cover the retry. Use a spike as the cue to /router pin
or deny a model for the session.
Request ids
Every turn is recorded under the id of the HTTP request it answered, so an id a customer quotes names that turn instead of "whichever turn of theirs was closest in time".
- Read.
X-Request-Idon/v1/chat/completions,/v1/responsesand/v1/messages. It is opaque, and it comes from outside, so it is accepted only when it is at most 128 characters of letters, digits and. _ - : + =(every id in circulation: a UUID, a ULID, atraceparent, a proxy's own). Anything else — too long, a control character, a newline, whitespace — is refused whole rather than truncated or stripped: a repaired id looks valid and matches no row. - Minted when absent. A caller that sends no usable header gets one minted,
prefixed
amr-, so every turn is addressable and a direct user of the router has what a front door's customers have. The prefix is also refused inbound: no caller can pass an id off as one the router minted. - Returned. The turn's response carries
x-request-id(the caller's value unchanged, or the minted one), and every ledger row of the turn — each escalation attempt, and a digest taken during it — carries it asrequest_id, reported asrequestIdonGET /v1/router/decisions.?requestId=there filters to exactly those rows; an id nothing was recorded under answers with no entries, and a mangled one is a 400 rather than a silently ignored filter. - Both directions. A caller that sends no header behaves exactly as before.
A row written before v0.37.0 reads as having no id (
null), never as an error, and is still found by every other filter./healthlistsrequest-idinfeatures, so a front door can tell whether the router it is talking to records ids before it relies on them.
Spend follows the ledger's rule — the provider's reported cost when it gave
one, else the usage-priced figure the router computed, else the forecast.
Speed uses only clean streamed rows (TTFT recorded, no error); tokens/s is
completion tokens over time after first token. Ollama Cloud caches prompt
prefixes and bills them at its cached rate but reports no count, so the
router estimates it (see Ollama Cloud); cache rates that
include such rows are shown with a ~.
Configuring the router
The router's own config lives at $AUTO_MODEL_ROUTER_HOME/config.yml (default
~/.auto-model-router/config.yml). Every key is optional — unset keys use the
built-in defaults below. There are two ways to edit it:
Via /router (in-omp, native UI)
Install the router-configure extension, restart omp, then run /router in
the session prompt. With no arguments it shows a menu (Configure, Report,
Status); the subcommands go straight there:
| Command | What it does |
|---|---|
/router config |
Section picker over every config key: Server, OpenRouter, Ollama Cloud, Benchmarks, Tiers, Tasks, Filters, Classifier, Escalation, Hysteresis, Exploration, Cache, Compaction, Context (agentdox), Budget, Ledger, Logging, Profiles. Only ollama.prices and ollama.twins (maps) stay YAML-only. |
/router report |
Usage analytics in a fullscreen hub styled like /models: pick a view in the sidebar, set the window (24h / 7d / 30d / 90d) and the harness scope there too. /router report 30d --all presets them. See Usage reports. |
/router summary [--all] |
The last 24 hours in a few lines: spend against the day before, turns and conversations, cache hit, escalations, errors, model switches with tier moves, top models, savings against the first report.baselines model, digests and subagent spend, soft-failure spikes, and the Ollama meter with its runway. Posted automatically once a day at session start when report.dailySummary is on (the router keeps a per-harness marker, so several omp windows show it once between them, and a day with no turns and no spikes is skipped). |
/router status |
The router's /health: key sources, catalog size and age, Ollama availability, plan usage and cost bias, soft-failure spikes (below), agentdox bridge. |
/router why |
Explain this session's last routed turn: model and provider, tier, classification source and confidence, cost, cache hit, latency, the full decision trail and classifier reasons, any feedback already given. |
/router good / /router bad [note] |
Judge that turn. Recorded against the model that served it (POST /v1/router/feedback), shown per model in the report's feedback column, and the label the de-escalation work needs. /router feedback good|bad is the same. |
/router pin <model|off> |
Route this session to one model until cleared (admitted past price, quality and trust filters; tool support and context window still apply). Escalations and failovers after the first attempt still run. |
/router tier <tier|off> [turns] |
Force a tier for N committed turns (default 10; 0 = until cleared). Shown with no argument. Overrides are per omp session, live in the router process only, and lapse after 12 idle hours. |
Picking a section lists its fields with their current values (pending edits
marked), so you see the settings before choosing one to change. Each field
dialog names the current value in its title, marks it in pickers and uses it
as the placeholder — empty input keeps it, - clears an optional field,
credentials show as set/unset and are never echoed. Save and exit writes the merged config
(schema-checked and backed up first). Tier, task, filter, classifier,
hysteresis, exploration, compaction, cache and budget changes hot-reload;
restart omp for server (except subagentProfile), openrouter, context,
ledger.path and the Ollama connection keys; ollama.costBias,
ollama.biasUntilUsage and ledger.retentionDays hot-reload too.
Via auto-model-router config (text wizard / CLI)
auto-model-router config
Same fields, prompted on the terminal. Also:
auto-model-router config --print— prints the OpenAI-compatible provider block ready to paste intomodels.ymlor your harness config.auto-model-router config --write— merges that block into omp'smodels.ymlautomatically.
Both write paths validate the merged file against the schema before touching
disk and back up the previous file to a timestamped .bak.
Configuration file location
- Router config:
$AUTO_MODEL_ROUTER_HOME/config.yml(default~/.auto-model-router/config.yml). - Ledger DB:
$AUTO_MODEL_ROUTER_HOME/router.db(SQLite, WAL), or apostgres://URL inledger.pathwhen two replicas must share one store — see Sharing the store.
Environment variables
| Variable | Purpose | Default |
|---|---|---|
OPENROUTER_API_KEY |
OpenRouter key (overrides the auth store). | — |
AUTO_MODEL_ROUTER_HOME |
Config + database directory. | ~/.auto-model-router |
AUTO_MODEL_ROUTER_HOST |
Bind address override. | 127.0.0.1 |
AUTO_MODEL_ROUTER_LOG |
Log level: silent/error/warn/info/debug. |
info |
AUTO_MODEL_ROUTER_LOG |
Log level: silent/error/warn/info/debug. |
info |
AUTO_MODEL_ROUTER_DB |
Override the ledger path. | $AUTO_MODEL_ROUTER_HOME/router.db |
AUTO_MODEL_ROUTER_URL |
Toast/base URL override (the toast reads the shared port file first). | — |
AUTO_MODEL_ROUTER_API_KEY |
Client bearer for the toast poll when server.apiKey is set. |
— |
OMP_HARNESS_ID |
Per-harness toast scoping. | — |
AUTO_MODEL_ROUTER_TOAST |
compact for the one-line toast; anything else keeps the decision trail. |
verbose |
Configuration reference
This is the complete set of settings, grouped by section, with defaults and what each one does. All values are optional; omit a key to use its default.
server
| Key | Default | Meaning |
|---|---|---|
host |
127.0.0.1 |
Bind address. 0.0.0.0/:: listen on all interfaces (the provider still advertises loopback). |
port |
0 |
Bind port. 0 = let the OS pick a free ephemeral port (the embedded router's default). |
apiKey |
unset | Optional client bearer token. When set, every request must send Authorization: Bearer <key>. |
subagentProfile |
auto-sub |
Profile omp subagents are routed under when they ask for the default one. The embed extension marks sessions without a UI with X-Omp-Subagent: 1; delegated work (reads, searches, summaries) never needs the top tier. Empty disables the remap. |
harnessId |
unset | Harness identity sent as X-Omp-Harness; scopes per-harness daily budgets and toasts. |
openrouter
| Key | Default | Meaning |
|---|---|---|
baseUrl |
https://openrouter.ai/api/v1 |
Upstream OpenRouter endpoint. |
apiKey |
unset | OpenRouter key. Falls back to OPENROUTER_API_KEY, then omp's auth store. |
referer |
unset | HTTP Referer header sent upstream (OpenRouter attribution). |
title |
auto-model-router |
Attribution title sent upstream. |
timeoutMs |
600000 (10 min) |
Upstream request timeout. Agent turns stream for minutes, so keep this high. |
catalogTtlMs |
21600000 (6 h) |
How long the model catalog is cached before a forced refetch. |
catalogRefreshMs |
300000 (5 min) |
Background catalog refetch interval; 0 disables it. |
ollama — Ollama Cloud as a second upstream
Off by default. When enabled, Ollama Cloud models join the same catalog as
OpenRouter's under ollama/<id> slugs and are ranked on the same economics:
a turn picks whichever provider's model is cheapest above the tier's floor,
and same-tier failover crosses providers (a 402 or 429 from Ollama retries on
an OpenRouter sibling). See Ollama Cloud below.
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch. |
baseUrl |
http://127.0.0.1:11434/v1 |
A local daemon (proxies :cloud models under its sign-in) or https://ollama.com/v1. |
apiKey |
unset | Bearer for ollama.com. Resolved from config, then OLLAMA_API_KEY, then omp's own auth store (/login ollama-cloud in omp) — the same borrowing as the OpenRouter key. The daemon needs none. |
timeoutMs |
600000 |
Per-request timeout. |
catalogTtlMs |
300000 |
Re-list models when the last listing is older than this. |
includeLocal |
false |
Also expose the daemon's local models (only those named in prices). |
prices |
{} |
USD per million tokens by bare cloud name ({input, cachedInput?, output}); overrides or extends the shipped snapshot. |
twins |
{} |
Bare cloud name → OpenRouter slug, to pin a quality-score twin the name match misses. |
costBias |
1 |
Multiplier on Ollama models' effective cost in ranking; below 1 prefers Ollama. The ledger still records list price. |
biasUntilUsage |
0.9 |
Share of the plan's included monthly credits at which costBias switches off and Ollama ranks at list price. Read live from ollama.com's /api/usage, which reports usage relative to the plan, so the same value is right on Pro, Max or Team. 1 keeps the bias regardless. |
usagePollMs |
600000 (10 min) |
How often plan usage is re-read. 0 disables it (static bias). Needs the API key; the daemon path without one keeps a static bias. |
quotaCooldownMs |
900000 |
Route around Ollama this long after a 402 (credits exhausted). |
rateLimitCooldownMs |
60000 |
Route around Ollama this long after a 429 (concurrency cap). |
planCreditsUsd |
0 |
Override for the plan's included monthly credits. 0 detects the plan from ollama.com (POST /api/me) and applies its published allowance (Pro $60, Max $300), so /health and /router status show ollama.com's reading as dollars next to the ledger's figure. Set it for a plan the router does not know. |
tiers — per-tier economic envelope
Each tier (trivial, simple, moderate, hard) is a tierConfig:
| Key | Default | Meaning |
|---|---|---|
minQuality |
0/40/60/72 |
Minimum quality score (on the task's axis) a model needs to be eligible. 0 admits unscored models. |
maxInputPerMtok |
0.3/1.5/4.0 (hard: none) |
Price ceiling on input, USD per million tokens. hard has no ceiling. |
maxOutputPerMtok |
unset | Optional output price ceiling, USD per million tokens. |
qualityExponent |
0/0/1/3 |
How strongly quality beats price when ranking candidates. 0 = cheapest above the floor; higher = prefer quality. |
pin |
[] |
Force specific model slugs into this tier (they bypass the floor/ceiling). |
tasks — per-task-type capability and quality
Each task (coding, vision, documentation, data, chat) is a
taskConfig:
| Key | Default | Meaning |
|---|---|---|
axis |
coding→coding, others→intelligence |
Which quality axis to score on. |
minQuality |
unset | RAISES the tier floor for this task (never relaxed by adaptive floors). |
requireImage |
vision: true, others unset |
Require image input support. |
prefer |
[] |
Preferred model slugs for this task. |
filters — candidate allow/deny and trust
| Key | Default | Meaning |
|---|---|---|
allow |
[] |
Glob allowlist; when non-empty, only matching slugs are eligible. |
deny |
[] |
Glob denylist; matching slugs are excluded. |
includeFree |
false |
Include free models (rate-limited hard; usually excluded). |
requireToolSupport |
true |
Only models that support tool calls. |
feedbackWeight |
0 |
How much a /router good|bad verdict weighs in a model's trust rate: a bad verdict counts as this many failures, a good one as this many successes. 0 records verdicts without acting on them. |
feedbackByTask |
false |
Count a verdict only when routing the same task type as the judged turn (coding, vision, documentation, data, chat), so a model that codes well but explains badly keeps its coding trust. Verdicts on turns with no recorded task count everywhere. |
minTrust |
0.7 |
Minimum success rate; models below this (after minTrustSamples) are demoted. |
minTrustSamples |
12 |
Attempts before trust is enforced. |
trustScopedByHarness |
false |
true = each harness reads only its own trust rows. |
contextHeadroom |
1.25 |
Fraction of context kept free (a model must fit prompt × this). |
reasoningCompletionFloor |
512 |
Smallest completion budget a reasoning model is dispatched with. A reasoning model spends the budget thinking before it answers, so a caller's tight cap (omp asks ~12 tokens for a conversation title) returns nothing: measured on ollama/gpt-oss:20b, which hit the cap having produced no content and cost a dead dispatch plus a failover. Raised only for models that reason, never above the model's own ceiling, and a cap is an upper bound so a direct answer still stops early. 0 disables. |
latencyWeight |
0 |
How hard to penalise slow models in scoring (soft multiplier on effective cost). 0 disables it. |
latencyMinSamples |
20 |
Streamed samples before latency is judged against a model. |
cacheReliabilityMinSamples |
10 |
Warm-expected samples before a model's observed cache hit rate discounts its "stay warm" price in the stay/switch comparison. A model whose cache misses when it should be warm (measured: 5-6% on glm/gemini, 11% on ling, 50% on nex) is kept less eagerly. 0 assumes every cache is reliable. |
latencyWeightContinuation |
unset | Latency weight on tool-result continuations (the agent loop's own follow-ups). Unset ⇒ latencyWeight everywhere; lower it to spend speed only where a person waits on first token. |
maxExpectedWaitMs |
unset | Absolute expected-wait ceiling (ms): a hard drop for models proven slower (≥ latencyMinSamples), regardless of price. The soft penalty is multiplicative and capped, so it cannot demote a slow-but-cheap model — this can. New models keep their cold-start turns; relaxed with trust in tier rescue. Undefined ⇒ off. |
escalationCostWeight |
0 |
Price a model's measured escalation rate at what an escalated retry actually bills (the ledger's $/prompt-token of attempt > 0 rows), 0–1. The trust divisor reads a 4% escalation rate as a 4% surcharge; the real cost is a whole re-dispatch on the next tier's model. 0 disables the term. |
classifier — complexity adjudication
| Key | Default | Meaning |
|---|---|---|
ambiguityThreshold |
0.6 |
Below this heuristic confidence, the adjudicator model decides the tier. |
learnedModelPath |
unset | A model written by bun tools/train-classifier.ts (logistic regression over the ledger's recorded features; label = the turn escalated, or with --label feedback the turn was judged bad via /router bad). When set, every decision records learned: p(escalate)=… or learned: p(bad)=…. Advisory only: it never moves a tier until replay shows it should. |
model |
qwen/qwen3.7-flash |
Adjudicator model slug. |
maxCostFraction |
0.02 |
Adjudicator cost cap as a fraction of the turn's budget. |
maxCostUsd |
0.002 |
Absolute adjudicator cost cap, USD. |
timeoutMs |
4000 |
Adjudicator request timeout. |
cacheSize |
512 |
Adjudication result cache size. |
toolAxis |
coding |
Quality axis for tool-heavy turns. |
chatAxis |
intelligence |
Quality axis for chat turns. |
agenticLoopDepth |
3 |
Tool-loop depth at which a turn is treated as agentic. |
readOnlyToolWeight |
0 |
Score subtracted when a tool-result continuation follows an assistant turn that used only read-only tools (read, grep, glob, ls, lsp…). Recorded as features.readOnlyToolTail either way; enable after tools/replay.ts prices it. |
mechanicalRetryFactor |
0.2 |
Fraction of the failed-tool and circular-call weights kept on a tool-result continuation; 1 disables the damping. |
escalation — mid-stream retry upward
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Enable the mid-stream escalation guard. |
probeTokens |
48 |
Tokens held before deciding whether to escalate. |
maxHoldMs |
8000 |
Max time to hold the first tokens waiting for a verdict. |
maxAttempts |
3 |
Original try + retries. Direct dial between reliability and wasted spend. |
probeTiers |
["trivial","simple","moderate"] |
Tiers that may escalate upward (hard has nowhere to go). |
triggers |
5 signals | malformed_tool_args, refusal, empty_completion, repeat_tool_call, missing_expected_tool_call. |
escalateOnLengthStop |
true |
Escalate on a length finish that truncated tool-call args. |
The model that produced the rejected output never serves the retry, at this
tier or the next. Signals that indict the provider rather than the tier —
empty_completion, refusal, content_filter, and an error finish — first
try a different model in the same tier (bounded, like a 5xx failover) and
only then step up; structural signals (malformed_tool_args,
repeat_tool_call, a truncated tool call) escalate a tier directly. A client
that hangs up after the finish event has already arrived is treated as a
completed turn, not an error.
A content_filter finish (the provider's filter stopping the completion,
including Anthropic's refusal stop reason, which maps to it) rides the
refusal trigger: the same failure class — the provider declining the work —
so a config that already opts into refusal escalation gets it without a
change. A filter finish that produced NO text is an empty_completion
instead: nothing was filtered, so a plain retry is the fix.
hysteresis — cache-aware model stickiness
| Key | Default | Meaning |
|---|---|---|
holdTurns |
2 |
Hold a chosen model this many turns before it can downgrade. |
holdTurnsAfterEscalation |
4 |
Hold longer after an escalation. |
switchMargin |
1.3 |
Switching must beat the warm-cache discount by this factor. Lower = switch away from a warm model more readily. |
switchHorizonTurns |
1 |
Turns the stay/switch comparison is amortised over: H × stayWarm vs switchCold + (H − 1) × newWarm. 1 is the one-turn comparison, which can keep a dear model warm indefinitely when the cheaper winner is itself dear cold; a small H lets a switch that pays for itself within a few turns go ahead. |
confirmUpgradesBelowConfidence |
0.6 |
A heuristic tier upgrade classified below this confidence waits one turn while the current model's cache is warm; a second consecutive upgrade classification confirms it. Escalations, explicit high reasoning and failing tool loops bypass the wait. 0 disables. Measured: 65 of 67 moderate→hard upgrades in a week bounced back within 3 turns, each paying a cold hard-tier read of a ~120k prompt. |
cacheWarmTtlMs |
300000 (5 min) |
How long a model's prompt cache is considered warm. |
maxDowngradePerTurn |
1 |
Max tiers a turn may drop in one step (avoids quality cliffs). |
breakHoldOnMechanical |
false |
Let a tool-result continuation that classifies below the held tier escape the hold (still bounded by maxDowngradePerTurn). Worth enabling when the held tier is expensive. |
compaction — shrink stale tool output before dispatch
Off by default; see docs/context-optimization.md. Every edit shrinks one
tool-result's content in place behind a re-run breadcrumb, never removes or
reorders a message, and the plan is persisted per conversation so already
shrunk results stay shrunk (rewriting them would break the prompt cache).
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch. |
budgetTokens |
40000 |
Compact when the (already compacted) prompt exceeds this many tokens. |
floorRatio |
1 |
Once compaction fires, compact down to this fraction of the budget so the plan holds for several turns. |
replanGrowthRatio |
1 |
Above 1, only extend an existing plan once the compacted prompt has grown by this factor since the plan was made. Rations plan churn when the budget is unreachable (every turn over budget); fit-to-window is never rationed. |
fitToWindow |
true |
Also compact when the prompt would overflow the profile's context window. |
protectRecentTurns |
4 |
Never touch the last N user/assistant turns or the volatile tail. |
maxToolResultBytes |
4096 |
Tool results larger than this (outside the protected window) are truncated. |
keepHeadBytes / keepTailBytes |
512 / 512 |
Bytes kept around the elision breadcrumb. |
elideSupersededReads |
true |
Stub an older result when a newer call to the same resource supersedes it. |
collapseDuplicateResults |
true |
Collapse byte-identical repeated results to a single copy. |
digestToolResults |
false |
Summarising compaction: when the plan gains an edit, a cheap model (digest.tier/digest.model, under digest.maxCostUsd and digest.timeoutMs) digests the tool result instead of it being cut to head+tail or a stub. The digest is stored on the edit, so the dispatched bytes stay identical on later turns and the cache holds. Applies when the turn routed at or above digest.fromTier; works without digest.enabled. |
digestMaxPerTurn |
2 |
Digests per turn at most (largest results first); the rest of a plan's new edits stay plain until a later turn. |
cache — prompt-cache breakpoints
| Key | Default | Meaning |
|---|---|---|
injectBreakpoints |
true |
Insert prompt-cache breakpoints into long prompts. |
maxBreakpoints |
4 |
Max breakpoints (Anthropic allows 4; OpenRouter translates). |
minPromptTokens |
2048 |
Minimum prompt size before breakpoints are injected. |
budget — cost caps
| Key | Default | Meaning |
|---|---|---|
perTurnUsd |
unset | Per-turn cap (checked against the cold forecast). |
perConversationUsd |
unset | Per-conversation cap. |
perDayUsd |
unset | Rolling 24h cap, scoped per harness when harnessId is set. |
perMonthUsd |
unset | Calendar-month (UTC) target. Paced: the daily cap becomes min(perDayUsd, remaining ÷ days left), so a month running ahead tightens automatically. The breach reason names the pace. |
onExceeded |
downgrade |
downgrade = pick the cheapest viable model; reject = fail the turn. |
profiles — the virtual models omp sees
Each profile is a complete entry (arrays replace wholesale):
| Key | Default | Meaning |
|---|---|---|
id |
auto / auto-cheap / auto-max / auto-sub |
Model id omp selects. auto-sub (trivial..moderate) is what subagents get via server.subagentProfile. |
name |
Auto (auto-model-router) etc. |
Display name. |
minTier / maxTier |
trivial/hard, trivial/simple, moderate/hard |
Tier envelope. |
contextWindow |
400000 |
Advertised context window (drives omp's compaction). |
maxTokens |
32000 |
Advertised max output tokens. |
budget |
unset | Per-profile budget overrides. |
digest — cheap-model digest of large tool results
Tool results are the bulk of every prompt (see the report's prompt anatomy),
and a prompt is ~96% of spend. With the router-digest extension installed
and digest.enabled on, a large read, grep, glob or bash result produced
while the session's current model is at or above fromTier is sent to
POST /v1/router/digest; the cheapest tier model rewrites it to what the
task needs (exact paths, line numbers, names, errors, code to be edited) and
the digest replaces the tool result. It begins with a marker naming the tool
and arguments to re-run for the full output, so nothing is lost, only
deferred. Errors, images, edits and writes are never digested. Every digest
is a ledger row (requestedModel digest) and the report totals them.
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch; the extension polls it every minute. |
minBytes / maxBytes |
12000 / 400000 |
Result size window that gets digested. |
tools |
read, grep, glob, bash, web_fetch, webfetch, ls, find |
Eligible tool names (lower-case). |
toolAliases |
Hermes, Cline/Roo/Kilo, Codex and OpenCode spellings (read_file → read, search_files → grep, terminal/execute_command/shell → bash, …) |
Harness tool names mapped onto the canonical tools list, so one list serves every harness. |
fromTier |
moderate |
Digest only when the session's current model is at or above this tier. |