Use the cheapest model that can do the job. Automatically.
An OpenCode plugin that routes every coding task to the right-priced AI tier — automatically, on every message, with 3,084–4,654 characters of system-prompt overhead depending on the orchestrator and enforcement mode.
Most AI coding tools give you one model for everything. You pay Opus prices to run grep. opencode-model-router changes that with a stack of interlocking ideas:
Use a mid-tier model as orchestrator. The orchestrator runs on every message. Put Sonnet there, not Opus. Sonnet reads a routing protocol and delegates just as well as Opus — at 4x lower cost. Reserve Opus for when it genuinely matters.
Inject a compressed, LLM-optimized routing protocol. Instead of duplicated prose, the plugin injects a dense, machine-readable routing protocol. The protocol itself is 3,084 characters; a Claude orchestrator receives 3,856 characters after its authority prefix (roughly 965–1,075 tokens at 3.6–4.0 characters per token). Every message, every session.
Match task to tier using a configurable taxonomy.
A keyword routing guide (@fast→search/grep/read, @medium→impl/refactor/test, @heavy→arch/debug/security) tells the orchestrator exactly which tier fits each task type. Fully customizable. No ambiguity.
Split separable composite tasks: explore cheap, execute smart. "Find how auth works and refactor it" shouldn't cost @medium for the whole thing. The multi-phase guidance prefers a split when phases are separable: @fast reads the files (1x cost), @medium does the rewrite (5x cost). ~36% savings on composite tasks, which are ~65% of real coding sessions.
Skip delegation overhead for trivial work. Single grep? One file read (or a quick follow-up)? The orchestrator can execute directly — zero delegation cost, zero latency.
Four routing modes for different budgets.
/budget normal (balanced), /budget budget (aggressive savings, defaults everything to @fast), /budget quality (liberal use of stronger models), /budget deep (heavy-first for long architecture/debug runs). Mode persists across restarts.
Cost ratios in the prompt.
Every tier carries its costRatio (fast=1x, medium=5x, heavy=20x) injected into the system prompt. The orchestrator sees the price before deciding. It picks the cheapest tier that can reliably handle the task.
Orchestrator-awareness.
If the orchestrator is already running on Opus, the rule self∈opus→never→@heavy fires — it does the heavy work itself rather than delegating to another Opus instance.
Multi-provider support with automatic fallback.
Four presets out of the box: Anthropic, OpenAI, GitHub Copilot, Google. Switch with /preset. If a provider fails, the fallback chain tries the next one automatically.
Plan annotation for long tasks.
/annotate-plan reads a markdown plan and tags each step with [tier:fast], [tier:medium], or [tier:heavy] — removing all routing ambiguity from multi-step workflows.
Fully configurable.
Tiers, models, cost ratios, rules, task patterns, routing modes, fallback chains — all in tiers.json. No code changes needed.
Vibe coding is expensive because most AI coding tools default to one model for everything. That model is usually the most capable available — and you pay for that capability even when the task is grep for a function name.
A typical coding session breaks down roughly like this:
| Task type | % of session | Example |
|---|---|---|
| Exploration / search | ~40% | Find where X is defined, read a file, check git log |
| Implementation | ~45% | Write a function, fix a bug, add a test |
| Architecture / deep debug | ~15% | Design a new module, debug after 2+ failures |
If you're running Opus (20x cost) for all of it, you're overpaying by 3-10x on most tasks.
opencode-model-router injects a delegation protocol into the system prompt that teaches the orchestrator to:
- Match task to tier using a configurable task taxonomy
- Split composite tasks — explore first with a cheap model, then implement with a mid-tier model
- Skip delegation overhead for trivial tasks (1-2 tool calls)
- Never over-qualify — use the cheapest tier that can reliably handle the task
- Fallback across providers when one fails
All of this adds 3,084 characters for a non-Claude orchestrator or 3,856 characters for a Claude orchestrator (roughly 770–1,075 tokens at 3.6–4.0 characters per token).
This intuition-first walkthrough was written by Lucas Húngaro for his fork, and is included here with his permission.
There are a lot of moving parts, so let's use an analogy to explain how the plugin works.
Think of your main orchestrator model as a head chef in a restaurant. You give it an order, and instead of cooking everything itself, it hands tasks to line cooks of different skill and price: a fast, cheap cook for simple tasks, a mid-level cook for everyday work, and an expensive expert for the hard problems.
The chef assigns each task by what the task is, not by who is the "fanciest" cook. Reading a file, searching the codebase, or looking something up goes to the cheap cook. Writing and editing code goes to the mid-level cook. Gnarly architecture, security work, or debugging that has already failed a couple of times goes to the expert. Trivial one-off lookups the chef just handles itself, since delegating those would cost more than it saves.
That is the routing in a nutshell. You pay expert prices only when a task genuinely needs an expert, which is exactly the point.
The enforcement layer is the kitchen's quality control. It exists because a cook saying "all done" is not the same as the dish being good.
It works in two parts. First, a recipe card travels with each order, so the chef and the cook agree up front on what "done" means (say, the tests pass, or a specific file exists). Second, when the work comes back, someone other than the cook who made it tastes it, and that taster is at least as senior as the cook who executed it. They check it against the recipe card, and if it falls short, a note goes back to the chef suggesting a redo, maybe by a more capable cook.
How strict all of this is comes down to one setting:
- Off: no checking. A cook says done and it goes out.
- Advisory (the default): everything still gets tasted and notes still get left, but nothing is ever held back. Gentle nudges only.
- Enforced: a dish that fails its check is held back and redone, or handed to a better cook, before it can leave the kitchen.
You can disable the enforcement layer entirely to save some tokens in the system prompt, at the cost of possible re-work if a subagent fails to meet the agreed-upon definition of "done." This is the enforcement feature.
Separate from that optional quality control, the kitchen has one rule that is always in force, no matter which setting above you pick: a cook only gets so many trips to the pantry on a given task before they have to either start cooking or hand back what they have so far. And if a cook keeps checking the same shelf over and over, they get a sticky note telling them to stop. This is not about whether the dish is any good, it just keeps anyone from vanishing into the walk-in for an hour of "research" instead of producing something. It applies even with enforcement layer turned off.
This is the read-only call cap feature. It prevents runaway "exploration" loops and redundant tool calls, which are a common source of wasted time and money.
For a large, multi-step job you do not have to let the chef work out each step on the fly. You can write the plan down and have the kitchen label every step on the ticket ahead of time with the cook who should handle it. Think of prepping a banquet where each course is pre-assigned to a station, so nobody is deciding in the middle of service.
This is the plan annotation feature. You can invoke it with /annotate-plan. More details in Plan annotation.
Some cooks arrive with strong habits from their previous job. Left alone they tend to wander the whole pantry "just to be thorough," or they announce what they are about to do ("now I'll dice the onions") instead of actually doing it.
For those cooks the plugin clips a short note to the top of their instructions: in this kitchen your job is the dish in front of you, not a tour of the pantry. It keeps them from burning time and money on busywork. This is the adversarial prefix. There is also an optional, off-by-default add-on (the anti-narration guardrail) that tells them to cook rather than describe cooking; turn it on only if a model keeps announcing it will do work but never really does it (this can be common on small/older models).
You can also tell the kitchen how to lean. One setting keeps things balanced, one pushes hard for savings and sends almost everything to the cheap cook (with the obvious quality trade-offs), one spends more freely when you want quality, and one goes expert-first for long, hard jobs. You also choose which suppliers the cooks come from (Anthropic, OpenAI, Google, GitHub Copilot, or a mix), and if a supplier is unavailable the kitchen falls back to another automatically. These are the budget modes, presets, and fallback chain features.
In summary, the chef decides who cooks, the quality control decides whether the finished plate is good enough to serve, and the remaining knobs let you set the kitchen's budget, priorities, and suppliers.
Scenario: 50-message coding session with 30 delegated tasks
Task distribution: 18 exploration (60%), 10 implementation (33%), 2 architecture (7%)
| Task | Count | Tier | Cost ratio | Total |
|---|---|---|---|---|
| Exploration | 18 | Opus | 20x | 360x |
| Implementation | 10 | Opus | 20x | 200x |
| Architecture | 2 | Opus | 20x | 40x |
| Total | 30 | 600x |
| Task | Count | Tier | Cost ratio | Total |
|---|---|---|---|---|
| Exploration (delegated) | 10 | @fast | 1x | 10x |
| Exploration (direct, trivial) | 8 | self | 0x | 0x |
| Implementation | 10 | @medium | 5x | 50x |
| Architecture | 2 | @heavy | 20x | 40x |
| Total | 30 | 100x |
| Task | Count | Tier | Cost ratio | Total |
|---|---|---|---|---|
| Exploration | 18 | @fast | 1x | 18x |
| Implementation (simple) | 7 | @fast | 1x | 7x |
| Implementation (complex) | 3 | @medium | 5x | 15x |
| Architecture | 2 | @medium | 5x | 10x |
| Total | 30 | 50x |
| Setup | Session cost | vs all-Opus |
|---|---|---|
| All-Opus (no router) | 600x | baseline |
| Sonnet orchestrator + router (normal) | 100x | −83% |
| Sonnet orchestrator + router (budget) | 50x | −92% |
Cost ratios are relative units. Actual savings depend on your provider pricing and model selection.
On every message, the plugin injects a 3,084-character routing protocol. A Claude orchestrator receives 3,856 characters after its authority prefix (roughly 965–1,075 tokens at 3.6–4.0 characters per token). The notation is intentionally dense and compressed — it's optimized for LLM comprehension, not human readability. An agent reads it as a precise routing grammar; a human might squint at it.
What the orchestrator sees (Anthropic preset, normal mode):
## Model Delegation Protocol
Preset: anthropic. Tiers: @fast=claude-sonnet-5(1x) @medium=claude-opus-5/high(5x) @heavy=claude-fable-5/max(20x). mode:normal
R: @fast→search/grep/read/git-info/ls/lookup-docs/types/count/exists-check/rename @medium→impl-feature/refactor/write-tests/bugfix(≤2)/edit-logic/code-review/build-fix/create-file/db-migrate/api-endpoint/config-update @heavy→arch-design/debug(≥3fail)/sec-audit/perf-opt/migrate-strategy/multi-system-integration/tradeoff-analysis/rca
Multi-phase: prefer explore(@fast)→execute(@medium) when phases are separable. Cheapest-first when practical.
1.[tier:X] tag in plan→delegate X 2.plan:fast/cheap→@fast | plan:medium→@medium | plan:heavy→@heavy 3.default preference: read-only→@fast | implementation→@medium 4.orchestrate=self,execute=subagent 5.trivial(≤1 tool call,no expected follow-up)→direct,skip-delegate 6.before @heavy: gather context first(usually via @fast); if already sufficient, dispatch directly 7.if self is opus: skip-@heavy(do locally), still route broader read-only exploration to @fast 8.min(cost,adequate-tier)
Err→retry-alt-tier→fail→direct. Chain: anthropic→openai→google→github-copilot
Delegate with Task(subagent_type="fast|medium|heavy", prompt="...").
Keep orchestration and final synthesis in the primary agent.
What each line means (for humans):
| Line | What it encodes |
|---|---|
Tiers: @fast=...(1x) @medium=...(5x) @heavy=...(20x) |
Model + cost ratio per tier, all in one compact token |
R: @fast→search/grep/... @medium→impl/... |
Full task taxonomy — keyword triggers for each tier |
Multi-phase: prefer explore(@fast)→execute(@medium) when phases are separable |
Preferred decomposition for separable composite tasks |
1.[tier:X]→... 5.trivial(≤1 tool call)... 6.before @heavy: gather context... |
Numbered routing rules in abbreviated form |
Err→retry-alt-tier→fail→direct. Chain: anthropic→... |
Fallback strategy in one line |
The orchestrator reads this once per message and applies it to every tool call and delegation decision in that turn.
The most impactful optimization. A composite task like:
"Find how the auth middleware works and refactor it to use JWT."
Without router → routed entirely to @medium (5x for all ~8K tokens)
With router → split:
- @fast (1x): grep, read 4-5 files, trace call chain (~4K tokens)
- @medium (5x): rewrite auth module (~4K tokens)
Result: ~36% cost reduction on composite tasks, which represent ~60-70% of real coding work.
| Feature | model-router | Claude native | oh-my-opencode | GSD | ralph-loop |
|---|---|---|---|---|---|
| Multi-tier cost routing | ✅ | ❌ | ❌ | ❌ | ❌ |
| Configurable task taxonomy | ✅ | ❌ | ❌ | ❌ | ❌ |
| Budget / quality modes | ✅ | ❌ | ❌ | ❌ | ❌ |
| Multi-phase decomposition | ✅ | ❌ | ❌ | ❌ | ❌ |
| Cross-provider fallback | ✅ | ❌ | ❌ | ❌ | ❌ |
| Cost ratio awareness | ✅ | ❌ | ❌ | ❌ | ❌ |
| Plan annotation with tiers | ✅ | ❌ | ❌ | ❌ | ❌ |
| Measured prompt overhead: 3,084–4,654 chars | ✅ | — | ❌ | ❌ | ❌ |
Claude native: single model for everything, no cost routing. If you're using claude.ai or OpenCode without plugins, you're paying the same price for grep as for architecture design.
oh-my-opencode: focused on workflow personality and prompt style, not cost optimization. No tier routing, no task taxonomy.
GSD (Get Shit Done): prioritizes execution speed and low deliberation overhead. Excellent at pushing through tasks fast, but uses one model — no cost differentiation between search and architecture.
ralph-loop: iterative feedback-loop orchestrator. Excellent at self-correction and quality verification. No tier routing — every loop iteration runs on the same model regardless of task complexity.
The core difference: the others optimize for how the agent works (style, speed, quality loops). model-router optimizes for what it costs — with zero compromise on quality, because you can always put Opus in the heavy tier.
Orchestrator: use claude-sonnet-4-5 (or equivalent mid-tier) as your primary/default model. Not Opus.
Why: the orchestrator runs on every message, including trivial ones. Sonnet can read the delegation protocol and make routing decisions just as well as Opus. You reserve Opus for when it's genuinely needed — via @heavy delegation.
In your opencode.json:
{
"model": "anthropic/claude-sonnet-4-5",
"autoshare": false
}Then install and configure model-router to handle the rest.
# In your opencode project or globally
npm install -g opencode-model-routerAdd to ~/.config/opencode/opencode.json:
{
"plugin": [
[
"opencode-model-router",
{
"type": "npm",
"package": "opencode-model-router"
}
]
]
}git clone https://github.com/your-username/opencode-model-router
cd opencode-model-router
npm installIn ~/.config/opencode/opencode.json:
{
"plugin": [
[
"opencode-model-router",
{
"type": "local",
"package": "/absolute/path/to/opencode-model-router"
}
]
]
}The full default configuration lives in tiers.json at the plugin root. There are two ways to customize it.
Anything in an overrides file is deep-merged over the bundled tiers.json on load — you only specify the keys you want to change; everything else falls back to the defaults. These files live outside the cache dir, so they are not wiped when the plugin updates. They're .jsonc, so // and /* */ comments and trailing commas are allowed.
There are two layers, applied lowest→highest priority:
| Layer | Path | Scope |
|---|---|---|
| Global | ~/.config/opencode/opencode-model-router.overrides.jsonc |
Your personal defaults across all projects |
| Project | <project>/.opencode/opencode-model-router.overrides.jsonc |
Per-project; commit it to share one routing config with your team |
The project file deep-merges over (and wins against) the global file, which in turn merges over the bundled defaults. So you can keep personal preferences globally while a committed project file unifies routing for everyone on the repo. The project file is found by searching upward from the working directory to the repo root (the nearest ancestor containing .git), so it is picked up even when you launch opencode from a subdirectory.
{
"presets": {
"github-copilot": {
"heavy": { "model": "github-copilot/claude-opus-4.8", "variant": "high" }
}
}
}The example above changes only the @heavy model/variant for the github-copilot preset; every other tier, preset, and setting keeps its bundled value. You can also override costRatio, tierCaps, rules, modes, enforcement, add an entirely new preset, etc. — any top-level key from tiers.json.
Run /router overrides to see both file paths, whether each exists, and the precedence order.
Merge semantics: objects merge recursively; arrays and scalars are replaced wholesale (so an overridden rules/whenToUse list replaces the default, it does not append). If a file is missing, malformed, or produces an invalid config, the plugin logs a [model-router] warning and drops just that layer (keeping the others) — a typo in one file can never break startup or discard a valid file.
An overrides file can add a brand-new preset, not just tweak the bundled ones. model is the only required field per tier. costRatio and steps are optional — when omitted they fall back to the conventional 1 / 5 / 20 and 30 / 50 / 120 (by tier name, the same values the bundled presets use); description/whenToUse are display-only.
@fast omits costRatio/steps, so it gets the defaults (1 / 30); @medium and @heavy set their own to match their real economics. Set costRatio whenever your models' relative costs differ from the default ladder — it's the price signal the orchestrator uses to pick the cheapest adequate tier. (Routing by task type — @fast=read-only, @medium=implementation, @heavy=architecture — comes from the shared taskPatterns/rules, so it works for any preset automatically.)
The effective values, including any defaults, are shown by /tiers.
Restart opencode after adding a new preset so its tier subagents get registered. (Each model's provider must itself be configured in your opencode.json.)
activePreset/activeMode/enforcement.modein an override file are defaults. Runtime selections —/preset,/budget,/router enforce— are persisted to the state file (~/.config/opencode/opencode-model-router.state.json), which is applied after the override files and therefore wins. So the override'sactivePresettakes effect until you switch at runtime; if it ever "isn't taking", it's because a past/presetleft a value in the state file — run/preset <name>again or delete that file. (The state file is machine-written, so the plugin never rewrites your hand-authored, commented override file.)
You do not have to wait on a plugin release when providers ship new models. opencode already resolves a live model catalog (models.dev plus your configured and authenticated providers), and the plugin reads it directly. No external fetch, no hardcoded list.
/router models [provider]lists the valid model ids for your configured providers, each annotated with the provider's default and anydeprecated,alphaorbetastatus. Copy an id straight into an overrides file.- Validation. Bare
/routerchecks the active preset's tier models against the catalog and reports any that are missing or deprecated, with the closest valid suggestions. A stale id surfaces immediately instead of failing silently on every subagent dispatch. The same check is logged once per session to the plugin console.
This is report-only. The plugin never changes your models for you: it tells you what is available and what to change, and you set it in the overrides file.
For an npm install, tiers.json is inside the cached package directory, not your ~/.config/opencode/ folder — typically:
~/.cache/opencode/packages/opencode-model-router@latest/node_modules/opencode-model-router/tiers.json
⚠️ Editing a copy elsewhere (e.g. under your globalnpmnode_modules) has no effect, and this cached file is overwritten on each plugin update. Prefer the overrides file above unless you are developing the plugin from a local clone.
The plugin ships with six presets (switch with /preset <name>):
anthropic (default):
| Tier | Model | Cost ratio |
|---|---|---|
| @fast | anthropic/claude-sonnet-5 |
1x |
| @medium | anthropic/claude-opus-5 (high) |
5x |
| @heavy | anthropic/claude-fable-5 (max) |
20x |
openai:
| Tier | Model | Cost ratio |
|---|---|---|
| @fast | openai/gpt-5.6-luna-fast |
1x |
| @medium | openai/gpt-5.6-terra-fast (high) |
5x |
| @heavy | openai/gpt-5.6-sol-fast (xhigh) |
20x |
github-copilot:
| Tier | Model | Cost ratio |
|---|---|---|
| @fast | github-copilot/claude-haiku-4.5 |
1x |
| @medium | github-copilot/claude-sonnet-5 |
5x |
| @heavy | github-copilot/claude-fable-5 |
20x |
google:
| Tier | Model | Cost ratio |
|---|---|---|
| @fast | google/gemini-3.5-flash-lite |
1x |
| @medium | google/gemini-3.7-flash |
5x |
| @heavy | google/gemini-3.1-pro-preview |
20x |
hybrid — Anthropic for exploration and heavy analysis, OpenAI for implementation:
| Tier | Model | Cost ratio |
|---|---|---|
| @fast | anthropic/claude-haiku-4-5 |
1x |
| @medium | openai/gpt-5.6-terra-fast (high) |
5x |
| @heavy | anthropic/claude-opus-5 (max) |
20x |
fable-effort — one model, three reasoning depths (see per-tier effort):
| Tier | Model | Effort | Cost ratio |
|---|---|---|---|
| @fast | anthropic/claude-fable-5 |
low |
1x |
| @medium | anthropic/claude-fable-5 |
high |
3x |
| @heavy | anthropic/claude-fable-5 |
xhigh |
6x |
Because the model string is identical across tiers, escalating a task keeps the prompt cache warm. The cost ratios are estimated token-spend multipliers, not price differences.
Any tier may set effort: low, medium, high, xhigh, or max. Precedence,
highest first:
thinking.budgetTokens(Anthropic) /reasoning.effort(OpenAI) — the explicit, provider-specific setting always wins, and a one-time warning names the tier.effort.
Unset means unset: no effort (and no reasoning_effort) key is registered at all.
| Model family | Registered as | Caveats |
|---|---|---|
| Anthropic | options.effort, incl. xhigh/max |
Requires the opencode-anthropic-fix plugin (commit 307aea9+ for fable/mythos). Non-adaptive Claude models (e.g. haiku) silently strip effort at the API layer, and without the plugin a top-level effort can break Claude-Code billing fingerprinting. |
| OpenAI | options.reasoning_effort |
Supports only low/medium/high; xhigh and max are downgraded to high with a warning. |
| Other (Google, …) | nothing | Dropped with a warning — no known mapping. Detection is by model family, not provider prefix, so Copilot-proxied ids (github-copilot/gpt-4o, github-copilot/claude-sonnet-4) take their family's knob above. |
An out-of-set value in tiers.json fails validation at load; one that arrives any other
way is ignored with a single warning. Full detail in
docs/CONFIG_REFERENCE.md.
Switch with /budget <mode>. Mode is persisted across restarts.
| Mode | Default tier | Behavior |
|---|---|---|
normal |
@medium | Balanced — routes by task complexity |
budget |
@fast | Aggressive savings — defaults cheap, escalates only when necessary |
quality |
@medium | Quality-first — liberal use of @medium/@heavy |
deep |
@heavy | Deep-analysis mode — heavy-first for architecture/debug/security with longer heavy runs |
{
"modes": {
"budget": {
"defaultTier": "fast",
"description": "Aggressive cost savings",
"overrideRules": [
"default→@fast unless edits/complex-reasoning needed",
"@medium ONLY: multi-file-edit/refactor/test-suite/build-fix",
"@heavy ONLY: user-requested OR ≥2 @medium failures"
]
},
"deep": {
"defaultTier": "heavy",
"description": "Deep analysis mode — prioritizes thorough architecture/debug work with long heavy runs",
"overrideRules": [
"default→@medium for implementation and multi-file changes",
"@heavy for architecture/debug/security/tradeoff-analysis by default",
"allow long heavy runs before fallback; avoid premature downshift",
"trivial(grep/read/glob)→direct,no-delegate",
"if task is composite and phases are separable: prefer explore@fast then execute@heavy"
]
}
}
}Heavy tool-call budget: @heavy.steps=120 by default across presets (raised from 60) to reduce premature cutoffs on long architecture/debug tasks.
Keyword routing guide injected into the system prompt. Customize to match your workflow:
{
"taskPatterns": {
"fast": ["search/grep/read", "git-info/ls", "lookup-docs/types", "count/exists-check/rename"],
"medium": ["impl-feature/refactor", "write-tests/bugfix(≤2)", "build-fix/create-file"],
"heavy": ["arch-design/debug(≥3fail)", "sec-audit/perf-opt", "migrate-strategy/rca"]
}
}Set costRatio on each tier to reflect your real provider pricing. These are injected into the system prompt so the orchestrator makes cost-aware decisions:
{
"fast": { "costRatio": 1 },
"medium": { "costRatio": 5 },
"heavy": { "costRatio": 20 }
}Adjust to actual prices. Exact values don't matter — directional signals are enough.
The rules array is injected verbatim (in compact form) into the system prompt. Default ruleset:
{
"rules": [
"[tier:X]→delegate X",
"plan:fast/cheap→@fast | plan:medium→@medium | plan:heavy→@heavy",
"default preference: read-only work → @fast; implementation → @medium",
"orchestrate=self,delegate=exec",
"trivial (≤1 tool call, no expected follow-up) → direct, skip-delegate",
"before dispatching @heavy: gather context first (usually via @fast); if context is already sufficient, dispatch directly",
"if self is opus: skip-@heavy (do locally); still prefer routing broader read-only exploration to @fast",
"min(cost,adequate-tier)"
]
}Rules in modes[x].overrideRules replace this array entirely for that mode.
Subagents carry a cap on their own read-only tool calls (grep/read/glob/ls) per dispatch. Enforcement is two-layered: prompt-level stop rules + runtime banners injected into tool results. Baselines (configurable via tierCaps — see below):
| Tier | Baseline cap | Orchestrator self-cap |
|---|---|---|
@fast |
8 | — |
@medium |
5 | — |
@heavy |
3 | — |
| Orchestrator (direct tools) | — | 2 per turn (prompt-level only) |
The orchestrator can override any subagent's cap per dispatch by including a directive in the Task prompt:
CAP:N— tighten or loosen to N calls (e.g.,CAP:3for a focused lookup).CAP:none— disable the numeric cap entirely (used inqualitymode and for@heavyindeepmode).CAP:noneis honored only when the dispatch prompt also contains areason:line. An unjustifiedCAP:nonesilently falls back to the tier baseline — uncapped work must be justified in the dispatch, not just requested.CAP:Nis unaffected.
Omitting the directive falls back to the tier baseline. Subagents may exceed their cap with a 1-line reason: in the return (target, not hard block).
A "resume" is a second chat.message to a session already tracked at the same tier — how an opencode task_id resume (re-prompting an existing subagent session instead of spawning a new one) reaches the plugin. On resume:
- the per-dispatch cap resets (
[cap: 1/8]again) — the resumed round gets a full budget, and re-reads theCAP:directive from the new dispatch text, gate included; - the redundancy map persists — re-reading a file you already read in the previous round still emits
[⚠ REDUNDANT: … call #1]; - cumulative usage persists and is bounded by a ceiling of
cap × 3(the current dispatch's cap). Exceeding it appends:
[cap: 2/3]
[⚠ CUMULATIVE BUDGET EXCEEDED: 10/9 across 4 dispatches — return now]
The ceiling follows the current cap, so a tighter resumed cap tightens the ceiling. CAP:none has no per-dispatch budget to derive from and therefore no cumulative ceiling. A retry (new session, e.g. an escalation to another tier) is not a resume: it gets fresh counters and an empty redundancy map. A session evicted by the idle-TTL sweep also comes back fresh.
The sweep exists so per-session bookkeeping does not grow without bound in a long-running opencode process. A session is evicted once it has been idle for 1 hour; every mutating entry point refreshes its stamp, and a tool call refreshes at start so a single long call cannot age out mid-flight. There are no timers: the sweep runs opportunistically off chat activity, throttled to at most once every 5 minutes, and covers the four session-keyed stores (subagent sessions, guard state, trajectory telemetry, and changed-file tracking).
The same accounting exists at the hard-guard layer: guard.budget resets per dispatch, with a cumulative ceiling of guard.budget × 3 enforced as cumulative_iteration_cap.
If you never resume a subagent session, nothing changes — a first dispatch's cumulative count equals its per-dispatch count, so the ceiling is unreachable and every banner is byte-identical to before.
Prompt-level rules alone are unreliable: many models (including strong ones like Opus 4.7) ignore "please stop at N reads" and loop on reconnaissance for tens of minutes. To address this, the plugin tracks read-only tool calls per subagent session and appends a banner to every read-only tool result via the tool.execute.after hook. The subagent sees this banner inside the tool's own response text — not as advisory system prompt noise — which makes it very hard to ignore.
What the subagent sees inside each grep/read/glob/ls result:
...normal tool output...
[cap: 3/5]
Approaching or hitting the cap:
[cap: 4/5]
[⚠ CAP WARNING: 1 read-only call(s) remaining before forced return]
[cap: 5/5]
[⚠ CAP REACHED (5/5): your NEXT response MUST be a return — do NOT make another read-only call. Start the response with DONE:, NEED MORE:, NEED CONTEXT:, SCOPE GROWTH:, or ESCALATE:.]
Redundancy (same file re-read, same grep pattern re-run):
[cap: 3/5]
[⚠ REDUNDANT: this is the same grep you ran at call #1. STOP now — repeated reads add no information. Return with DONE/NEED MORE/NEED CONTEXT/SCOPE GROWTH/ESCALATE.]
The orchestrator session is not tracked — its self-cap of 2 direct reads per turn is prompt-only. Tool counting applies only to sessions whose agent matches a registered tier name.
{
"tierCaps": {
"fast": 8,
"medium": 5,
"heavy": 3
}
}Values are positive integers. Missing tier → falls back to the hardcoded default (same numbers). Change these to tighten/loosen the baseline without editing any prompt.
Independent of the numeric cap, every subagent runs a redundancy check before each new tool call. On stop (cap reached, redundancy detected, scope satisfied, or runtime banner), the subagent returns with exactly one of:
| Return prefix | Meaning |
|---|---|
DONE: … |
Dispatch request fully satisfied — synthesize into final answer. |
NEED MORE: … (or NEED CONTEXT: for @medium, SCOPE GROWTH: for @heavy) |
Subagent needs another targeted round — orchestrator decides what to dispatch. |
ESCALATE: … |
Scope grew beyond the subagent's role — orchestrator re-routes. |
This keeps subagents from burning tokens on repeated lookups when they already have enough context. CAP:none lifts the numeric cap but does not disable the redundancy check — the runtime still injects [⚠ REDUNDANT] banners regardless of cap setting.
Mode interactions:
| Mode | Dispatch directive | Orchestrator self-cap |
|---|---|---|
normal |
baselines (omit directive) | ≤2 direct reads |
budget |
CAP:5 @fast, CAP:2 @medium, CAP:2 @heavy |
≤1 direct read |
quality |
CAP:none on all dispatches |
≤2 direct reads |
deep |
CAP:none on @heavy only; baselines elsewhere |
≤2 direct reads |
Each tier (@fast, @medium, @heavy) has a system prompt that describes its role, scope, call cap, and return protocol. To avoid duplicating the same string across every preset, the router uses a global default with per-tier override:
{
"tierPrompts": {
"fast": "You are @fast — ... (full global prompt)",
"medium": "You are @medium — ...",
"heavy": "You are @heavy — ..."
},
"presets": {
"anthropic": {
"fast": { "model": "anthropic/claude-sonnet-5", ... },
"medium": { "model": "anthropic/claude-opus-5", ... },
"heavy": { "model": "anthropic/claude-fable-5", ... }
}
}
}Resolution order per tier:
- If the preset's tier defines
"prompt": "..."inline → use it (per-tier override). - Otherwise → fall back to the default for the tier's prompt style:
tierPromptsGoalOriented[<tierName>](or the built-in goal-oriented default) when the tier resolves togoal-oriented, elsetierPrompts[<tierName>]. - If neither is set → the tier registers without a system prompt.
Two wordings of every default prompt ship: the enumerated tierPrompts above, and a shorter goal-oriented set. Which one a tier gets is decided by promptStyle, which defaults to auto (goal-oriented for strong models). See Prompt styles in the config reference — including which shipped presets are affected.
To replace a built-in goal-oriented prompt, set tierPromptsGoalOriented in the overrides
file. It is the goal-oriented twin of tierPrompts: an entry replaces the built-in default
for that tier name only, and tiers you do not list keep theirs.
{
// Only affects tiers that resolve to the goal-oriented style.
"tierPromptsGoalOriented": {
"medium": "You are @medium. Ship the change described in the dispatch, match the surrounding code, and return the files you touched plus the verification you ran."
}
}modelGenerations holds substring patterns matched against a tier's model ID, with case
and separators (., -, _) normalized on both sides — so opus-4-8 matches
opus-4.8, and a provider renaming a model across separators cannot silently change how
it resolves. One list exists:
| List | Default | What it drives |
|---|---|---|
strong |
["claude-fable-5", "claude-mythos-5", "opus-4-8", "claude-opus-5"] |
promptStyle: "auto" resolution — a match resolves to goal-oriented, everything else to prescriptive (isStrongModel in src/router/prompts.ts). |
Setting strong replaces the default list outright rather than extending it — include the
built-in patterns you still want. The list is curated per model, not by generation:
claude-sonnet-5 is a Claude 5 model that is deliberately not in it. Claude-specific
prompt prefixes and the anti-narration detector do not use these lists; they match the
model ID directly (isClaudeModel in src/router/protocol.ts).
{
"modelGenerations": {
// Keep the shipped patterns and add a locally-hosted strong model.
"strong": ["claude-fable-5", "claude-mythos-5", "opus-4-8", "claude-opus-5", "my-org/big-coder"]
}
}See Prompt styles in the config reference for the full description.
When to customize: if a specific provider/model in a preset needs different instructions (e.g. Gemini-specific tool format, tighter/looser caps for a weaker local model), add "prompt": "..." on that tier only. All other presets keep using the global.
{
"presets": {
"google": {
"fast": {
"model": "google/gemini-3.5-flash-lite",
"prompt": "You are @fast (Gemini-tuned variant) — ...",
...
}
}
}
}Anthropic models (served directly via anthropic/* or routed through other providers as */claude-*) ship with a large cached system prompt that primes them toward broad exploratory Read/Grep/Glob behavior. When such a prompt sits in front of your router instructions, primacy bias and prompt caching weaken the router's authority — subagents ignore caps, orchestrators run read-only work themselves instead of dispatching.
To counteract this, the router automatically prepends an adversarial opener to:
- The tier prompt for any tier whose
modelmatches a Claude identifier - The orchestrator delegation protocol when the session model is a Claude identifier
Detection is by model string, not preset. A hybrid preset that mixes providers (e.g. openai/* for @fast, anthropic/* for @medium and @heavy) gets the override only on its Claude-backed tiers.
Tone assignment:
| Target | Tone | Opener label |
|---|---|---|
@fast (Claude) |
Scoping — conversational | SCOPE NOTE |
@medium (Claude) |
Scoping — conversational | SCOPE NOTE |
@heavy (Claude) |
Override — firm | AUTHORITY OVERRIDE |
| Orchestrator (Claude) | Override — firm | AUTHORITY OVERRIDE |
@heavy and the orchestrator use the firmer tone because that's where reconnaissance loops were worst in observed sessions. @fast and @medium use a softer scoping note to avoid over-correcting legitimate multi-read tasks.
Detection rules:
anthropic/<anything>→ Claude<provider>/claude-<anything>(e.g.github-copilot/claude-sonnet-4-6) → Claude<provider>/<namespace>.claude-<anything>(e.g.bedrock/us.anthropic.claude-3-5-sonnet-...) → Claude- Everything else → untouched
No configuration is needed — the prefixes are always applied for Claude-backed tiers. If you want to disable them, override the tier's prompt field (per-tier overrides replace the whole prompt, including the prefix).
Some Claude models (historically, thinking-enabled Sonnet with the max variant) can produce progress narration instead of actual work, phrasings like "Still writing the X function...", "Now I'll implement Y...", "Let me add Z...", without the X/Y/Z ever appearing.
This guardrail is off by default. On the Claude orchestrator path, enabling it adds 650 characters per message (roughly 160–180 tokens at 3.6–4.0 characters per token), and its detector false-positives on normal productive phrasing like "Now I'll add the test" when the test does follow, so it is opt-in. Enable it only if you actually observe narration-without-production on your models:
{ "antiNarration": true }When enabled, it works on two layers:
1. Prompt-level clause (prevention). A dedicated ANTI-NARRATION block is appended to every Claude-backed tier prompt and to the Claude-backed orchestrator delegation protocol. It names the forbidden phrasings explicitly and requires concrete output to follow any such phrase. A carve-out preserves legitimate explanation/plan requests from the user.
2. Post-hoc detector (telemetry). An experimental.text.complete hook scans completed text for narration regex patterns. On match, it:
- Logs a warning to the plugin console:
[model-router] narration detected (session abc123): "Still writing the auth", "Now I'll add the tests" - Appends a visible banner to the text as it's rendered to the user:
[⚠ narration detected: "Still writing the auth", "Now I'll add the tests"]
The detector is not blocking — plugin hooks cannot modify tokens mid-stream. It signals post-hoc so you can spot the pattern in the UI and in logs, and judge whether the prompt-level clause is holding up.
Detected patterns (conservative set to minimize false positives):
Still (writing|implementing|working on|...) the XNow (I'll)? (write|implement|add|...) the XLet me (write|implement|add|...) (the )? XI'll (now)? (write|implement|...) the XGoing to (write|implement|...) the XContinuing (with|by ...ing) (the )? X
When antiNarration is enabled, the detector runs for all models (not only Claude), while the prompt-level clause is Claude-only, so non-Claude models get detector-only. With the default (antiNarration off), neither layer runs.
| Field | Type | Description |
|---|---|---|
model |
string | Full model ID (provider/model-name) |
variant |
string | Optional variant ("max", "xhigh", "thinking") |
costRatio |
number | Relative cost (1 = cheapest). Shown in prompt. |
thinking |
object | Anthropic thinking: { "budgetTokens": 10000 } |
reasoning |
object | OpenAI reasoning: { "effort": "high", "summary": "detailed" } |
effort |
string | Provider-agnostic reasoning effort: "low", "medium", "high", "xhigh", "max". See Per-tier effort. |
color |
string | Optional agent colour passed through to the opencode agent registration. |
description |
string | Shown in /tiers output |
steps |
number | Max agent turns |
prompt |
string | Optional per-tier system prompt override. Falls back to the style-appropriate default when omitted. |
promptStyle |
string | "prescriptive", "goal-oriented" or "auto" (default). Picks which default prompt the tier gets; ignored when prompt is set. |
whenToUse |
string[] | Use cases (shown in /tiers, not in system prompt) |
If you already have custom subagents (.opencode/agents/**), they do not get routed by default. An agent that declares no model inherits the model of whoever invoked it, usually your orchestrator, so a cheap read-only helper quietly runs at orchestrator prices.
subagentTiers maps your agent names to tiers, and the plugin repoints them at the active preset's models:
{
"subagentTiers": {
"ContextScout": "fast",
"CodeReviewer": "medium",
"SecurityReviewer": "heavy"
}
}The point of doing this here rather than in opencode.json is that these agents then follow /preset. Switch to openai and ContextScout moves to that preset's fast model along with @fast. No model IDs live in your agent files, and a committed project overrides file gives the whole team the same routing.
Getting the names right. The key is opencode's agent name: the frontmatter name: field when the agent declares one, otherwise the file's path-derived name (agents/team/helper.md becomes team/helper). Frontmatter wins, so an agent in subagents/core/contextscout.md with name: ContextScout is keyed ContextScout. Matching is case-sensitive.
Rules, all deliberate:
- Opt-in. No map, or an empty one, and nothing is touched.
- The map wins over the agent's own
model. This is the point, and it is what lets you delete hardcoded model IDs from agent files. If you want an agent to keep its pinned model, leave it out of the map. - List subagents only. Primary agents are the orchestrator, so pinning one to a tier defeats the routing. Agents already registered with a non-
subagentmode are skipped, but a markdown agent's mode is not visible when the map is applied, so a primary agent listed there would still be repointed. - Unknown tier names are skipped, not fatal. A map written for a preset that defines a tier this one does not must never break startup.
- Tier names are never overwritten. An entry keyed
fast/medium/heavyis ignored so it cannot clobber the plugin's own agents. - Variants are set explicitly. If the target tier has no
variant, any variant already on the agent is removed rather than inherited. An inherited variant from another provider fails at dispatch, not at load.
Anything not listed is left exactly as opencode built it.
Defines provider fallback order when a delegated task fails:
{
"fallback": {
"global": {
"anthropic": ["openai", "google", "github-copilot"],
"openai": ["anthropic", "google", "github-copilot"]
}
}
}The read-only cap banners described above are advisory: a well-behaved subagent will respect them, but nothing prevents a model from making one more read after the [⚠ CAP REACHED] banner. The enforcement layer turns delegation into a produce → verify → accept/escalate loop with independent acceptance and quality escalation. As of v1.3.0 it runs in advisory mode by default: every non-trivial delegation is verified and any miss surfaces a forcing-note, but nothing is ever hard-blocked (the DoD/acceptance section adds 798 characters to the orchestrator system prompt, roughly 200–220 tokens at 3.6–4.0 characters per token, and subagents may receive non-blocking guard banners). Set "mode": "off" — or run /router enforce off — to restore byte-for-byte-unchanged routing with zero added prompt tokens and zero new latency. Hard-blocks only activate in "mode": "enforced".
- Layer 1 — hard-block guard. A
tool.execute.beforehook throws before a disallowed tool call executes, stopping budget overruns, redundant reads, and throwaway-script sidesteps in subagent sessions. - Layer 2 — independent acceptance gate. A non-trivial delegation carries a Definition-of-Done (DoD) that is checked — deterministically or by an independent grader at ≥ the producer's tier — before the result is trusted. The producer never grades its own output. Two cases are skipped rather than checked: a trivial dispatch carrying only an inferred DoD, and a dispatch whose inferred DoD has no deterministic checks and which changed no files. An explicit
[acceptance]block is always verified. See docs/VERIFICATION.md. - Layer 3 — quality-escalation ladder. On a failed check: retry once, then escalate fast → medium → heavy, bounded by attempt and cost ceilings. The loop ends in an honest
status: unmetrather than a fabricated pass.
- Mode A — on-the-fly. The orchestrator delegates through the native
Task()tool — observed and verified automatically by the enforcement pipeline, and rendered inline in the TUI. (An optional, independently-verifieddelegatetool can be enabled viaexperimental.verifiedDelegateToolintiers.jsonorMODEL_ROUTER_VERIFIED_DELEGATE=1; it is hidden by default so delegation stays visible.) - Mode B — plan-annotated.
/annotate-planemits[tier:X]plus an[acceptance]block per task; the enforcement loop is wired up at execution time based on those annotations.
Advisory is the default. To change the level:
- Add or edit the
enforcementblock intiers.json—"mode": "off","advisory", or"enforced"(seedocs/CONFIG_REFERENCE.md). - Set
MODEL_ROUTER_ENFORCE=1to forceenforcedfor a session, orMODEL_ROUTER_ENFORCE=0to forceoff. - Run
/router enforce <off|advisory|enforced>from the chat to toggle at runtime.
Modes: off — no-op, byte-for-byte-unchanged routing (must now be set explicitly, since advisory is the default); advisory (default) — evaluates and surfaces guidance, never blocks; enforced — hard-blocks active, full produce → verify → accept/escalate pipeline.
Enforcement applies to subagent/delegate sessions only. The orchestrator session is never hard-blocked.
Per-tier override. enforcement.perTier maps a tier name to off, advisory or
enforced and wins over the global mode for delegations to that tier — useful to keep
@fast advisory while @medium and @heavy are enforced:
{
"enforcement": {
"mode": "enforced",
"perTier": { "fast": "advisory" }
}
}Three ceilings bound the verification pipeline so a stuck model cannot hang a delegation
forever. All are integers in milliseconds, live under enforcement.verify, and ship
explicitly in tiers.json:
| Key | Bundled default | Bounds |
|---|---|---|
delegateTimeoutMs |
600000 (10 min) |
One producer turn. |
graderTimeoutMs |
60000 (1 min) |
One grader turn. |
gateBudgetMs |
90000 (90 s) |
The whole acceptance gate. |
Each budget is per ladder attempt, not per delegation: a fast → medium → heavy
escalation gives every attempt its own fresh clock. On expiry the child session is
cancelled for real via session.abort rather than left running, and the attempt is
recorded as failed so the ladder advances — an expired budget never yields a fabricated
pass, only an honest status: unmet.
Raise a ceiling rather than removing it; 0 and negative values are rejected by
validateConfig, not read as "no timeout".
{
"enforcement": {
"verify": {
// A slow local model needs longer than the 10-minute default.
"delegateTimeoutMs": 1800000
}
}
}Full field notes are in docs/CONFIG_REFERENCE.md.
docs/ENFORCEMENT.md— architecture, hook wiring, session lifecycledocs/VERIFICATION.md— DoD schema, deterministic checks, grader dispatchdocs/ESCALATION.md— escalation ladder configuration and cost ceilingsdocs/CONFIG_REFERENCE.md— fullenforcementblock schemadocs/ENFORCEMENT_PRESETS.md— ready-to-paste enforcement presets
These files are not included in the npm tarball. This section is the self-contained summary; the docs are available in the repository for contributors and advanced users.
| Command | Description |
|---|---|
/tiers |
Show active tier configuration, models, rules, and each tier's resolved prompt style |
/preset |
List available presets |
/preset <name> |
Switch preset (e.g., /preset openai) |
/budget |
Show available modes and which is active |
/budget <mode> |
Switch routing mode (normal, budget, quality, deep) |
/annotate-plan [path] |
Annotate a plan file with [tier:X] tags for each step |
/router overrides |
Show the global + project override file paths and merge precedence |
/router models [provider] |
List valid model ids from your configured providers (with defaults and deprecated flags) |
/router enforce <off|advisory|enforced> |
Set delegation-enforcement mode (persisted) |
/router |
With no subcommand — or an unrecognized one — prints the /router help and the current enforcement mode |
/bypass [on|off] |
Toggle the router off/on for the session |
For complex tasks, you can write a plan file and annotate each step with the correct tier. The /annotate-plan command reads the plan and adds [tier:fast], [tier:medium], or [tier:heavy] tags to each step based on the task taxonomy.
The orchestrator then reads these tags and delegates accordingly — removing ambiguity from routing decisions on long, multi-step tasks.
Example plan (before annotation):
1. Find all API endpoints in the codebase
2. Add rate limiting middleware to each endpoint
3. Write integration tests for rate limiting
4. Design a token bucket algorithm for advanced rate limitingAfter /annotate-plan:
1. [tier:fast] Find all API endpoints in the codebase
2. [tier:medium] Add rate limiting middleware to each endpoint
3. [tier:medium] Write integration tests for rate limiting
4. [tier:heavy] Design a token bucket algorithm for advanced rate limitingMeasured with the bundled Anthropic preset in normal mode (the shipped activePreset/activeMode defaults), the routing protocol is 3,084 characters for a non-Claude orchestrator. A Claude orchestrator receives 3,856 characters after its authority prefix, or 4,654 characters when the 798-character DoD/enforcement section is enabled. That is roughly 770–1,295 tokens across the three paths at 3.6–4.0 characters per token. The optional anti-narration clause adds another 650 characters to the Claude path.
These are character counts of the prompts the shipped config actually produces, so they move whenever the protocol text does. test/unit/docs-drift.test.ts recomputes all three from tiers.json on every run and fails unless this section still quotes them, so a change that grows the protocol cannot land without updating these numbers.
| Version | Measured overhead | Features |
|---|---|---|
| Before v1.4.0 | Earlier README token estimates were not measured and were inaccurate | Routing, caps, and enforcement evolved across releases |
| v1.4.0 | 2,970–4,540 chars (~740–1,260 tokens at 3.6–4.0 chars/token) | Trimmed protocol; path-dependent Claude and enforcement prefixes |
| v1.6.0 | 3,089–4,659 chars (~770–1,295 tokens at 3.6–4.0 chars/token) | +119 chars on every path: CAP:none now has to be justified by a reason: line, and the protocol says so twice (rule 7 and the per-dispatch paragraph) |
- OpenCode v1.0 or later
- Node.js 20+
- Provider API keys configured in OpenCode
GPL-3.0
{ "presets": { "openrouter": { "fast": { "model": "openrouter/deepseek/deepseek-v3.2" }, "medium": { "model": "openrouter/qwen/qwen3-coder", "costRatio": 3, "steps": 60 }, "heavy": { "model": "openrouter/google/gemini-3-pro", "costRatio": 9, "steps": 140 }, }, }, // make it the active preset (or switch at runtime with `/preset openrouter`) "activePreset": "openrouter", }