Model routing
Reference for per-role model overrides (route config) and failover chains, and how they resolve.
On this page
Arcturn juggles several kinds of model call with very different quality requirements: the
main conversation loop needs the flagship; a delegated sub-agent's work is often focused
and mechanical; a compaction summary is lossy by design already; a session-title
suggestion is a few words. createModelRouter (packages/cli/src/router.ts) lets each of
these use a different model instead of hard-coding one everywhere. See
Model providers § Per-role routing for the narrative
version; this page is the config-and-precedence reference.
Route kinds
type RouteKind = "main" | "subagent" | "compaction" | "title";| Kind | Used for | Live call sites |
|---|---|---|
main |
The main conversation loop. | Resolved model for the session. |
subagent |
Delegated sub-agent work, scouts, and team members. | createSubagent and scoutAgent in runtime.ts; /team member dispatch and cost accounting in team.ts. |
compaction |
Summarizing history when the context window fills. | Intended for compactionOptionsFor; lossy work already, so a cheaper model costs nothing in quality that compaction wasn't already spending. |
title |
Session-title suggestions. | Reserved for future use — today's session title is derived from the task text directly, not an LLM call. |
Config shape
{
"model": "anthropic/claude-sonnet-4-5",
"route": {
"subagent": "anthropic/claude-haiku-4-5",
"compaction": "anthropic/claude-haiku-4-5"
}
}route is a RouterConfig: each of the four route keys is an optional model id string,
plus an optional tiers map covered below. Leaving a key out is not "no route" — it falls
through per the precedence below, so an unconfigured subagent route still gets a sensible
model rather than erroring. route is loaded like verify: a layer that sets it replaces
the whole block, it does not merge field-by-field with a lower layer's route — so a
project .arcturn/config.json that sets route.main and says nothing else drops a
user-level route.tiers entirely rather than inheriting it.
Tiers: portable model choices for role files and workflows
A markdown agent's model: frontmatter and a workflow step's [tag] (see
Workflows) both used to require a concrete catalog id
(model: anthropic/claude-opus-5) — fine for one deployment, dead on arrival for anyone
using a different provider, since every role file in a kit has to be hand-edited to retune
it. route.tiers fixes that with a layer of indirection: a config maps a handful of
symbolic names to whatever concrete id that deployment actually wants, and a role or step
names the tier instead of the vendor:
{
"route": {
"tiers": {
"judgment": "zai/glm-5.3",
"build": "zai/glm-4.7",
"cheap": "zai/glm-4.6"
}
}
}---
model: tier:judgment
---1. [tier:cheap] Reproduce this bug and quote the failing output: {{input}}
2. [tier:judgment] Write the minimal patch. Repro: {{prev}}tiers is an open string map — there is no fixed tier vocabulary, a fleet of role files
just needs to agree on names (judgment/build/cheap above are a suggestion, not a
reserved set). router.ts exports resolveModelTag(tag, router, resolve) as the one place
that decides whether a [tag]/model: string is a tier:<name> reference or a concrete
id: a tier: prefix resolves through router.specForTier(name), and anything else
resolves as a concrete id exactly as it always has — tiers are strictly additive, no
existing [tag] or model: value changes behavior.
Tier resolution follows the same never-crash contract as the rest of this module: a tier
name absent from tiers (including when tiers itself is unset) falls back to the
subagent route and records a warning via router.warnings(), rather than failing the
run — a tier a deployment hasn't gotten around to tuning yet must not be the reason a
portable role file cannot run. A configured tier whose id fails to resolve behaves like
any other route: caught, falls back to the router's fallback model, warns. Resolution is
lazy and cached per tier name, and router.rebind(newFallback) clears that cache too.
workflow.ts itself stays deliberately catalog-agnostic: a [tag] (or a role's model:,
for the worktree dispatch lanes) is handed as a raw string to whatever ModelTagResolver
the host injects, and it treats a tier:-prefixed string as just another valid tag — the
only workflow-side change tiers needed was widening the tag grammar to allow :. Whether
tier:judgment actually reaches route.tiers depends on that injected resolver being
built from resolveModelTag(tag, router, resolve) rather than a bare catalog lookup; a
host that wires the ModelTagResolver handed to createWorkflowCommands straight to
resolveModelSpec (ignoring tiers) still runs every existing workflow unchanged — a
concrete [anthropic/claude-opus-5] tag resolves exactly as before — it just won't
understand tier: tags until it composes in a ModelRouter built from config.route.
Precedence
main: config.route.main !== undefined ? resolve(config.route.main) : fallback
<other>: config.route[kind] !== undefined ? resolve(config.route[kind]) : specFor("main")fallback is the model the session otherwise resolved to — typically whatever --model
or config.model produced. So the effective chain for, say, subagent is: its own
configured route, else main's route, else the fallback model — the same "override,
then inherit" shape named agents
use for their own model: frontmatter, which itself sits one level above the subagent
route (agent's own model: wins first, route.subagent second, the running model third).
Resolution is lazy and cached per kind — nothing is resolved until
router.specFor(kind) is actually called, and after that the same ModelSpec is
returned without re-resolving. router.rebind(newFallback) clears the cache and adopts a
new fallback; call it after the session's main model changes (e.g. /model <id>), or
routes that defaulted to the old fallback keep resolving to it.
Failure handling: never blocks startup
If a configured route id fails to resolve — unknown model, a deregistered preset, a typo
— that's caught rather than thrown. The kind falls back to the router's fallback model,
and a warning is recorded (retrievable via router.warnings(), populated only once a
failing kind has actually been looked up). A stale model id left over in a config file
must never be the reason Arcturn fails to start or the main loop refuses to run.
Failover chains: config.model as a list
Separately from per-role routing, config.model (and --model) accepts either a single
id or an array:
{ "model": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"] }A list is a failover chain, not a round-robin or a preference order applied per turn.
The head is primary — it drives compaction budget and the cost readout — and the rest are
tried only when the current attempt fails before any output has streamed. The full
mechanics (which errors trigger a switch, why a start event is held back until one
model actually commits) live in @arcturn/ai's createFailoverClient, documented in
Model providers § Failover chains. The short version:
failover only ever happens pre-output, and only for transient errors
(rateLimit/overloaded/network) — an auth failure or a malformed request fails
identically on every link in the chain, so burning through it would be pointless.
resolveModelSpec(id, env) resolves one id at a time (and throws ModelResolutionError
on an unknown id or a missing API key); a model list is handled by mapping this function
over every entry, not by a separate list-aware resolver.
Cost rationale
Per-role routing exists so a long session doesn't have to downgrade the main loop just to
afford everything running alongside it — a cheap model for sub-agent delegation,
compaction summaries, and scouting keeps the aggregate bill down while the model actually
carrying the conversation stays the flagship. suggestCheapModel (router.ts) is a
heuristic for finding a cheaper same-provider, tool-capable candidate by input cost — it's
advisory only (meant for a future /model route --auto sketch) and is never applied
automatically today.
Related
- Model providers — the full picture: provider adapters, presets, failover, consensus, and the live model catalog this router resolves ids against.
- Sub-agents, plan mode & todos —
where the
subagentroute sits in a named agent's own model precedence. - Agent teams & background agents — the other consumer of
specFor("main")andspecFor("subagent"), for the supervisor and its members respectively. - Workflows — the
[tag]/@rolestep grammar tiers are meant to make portable, and the three dispatch lanes a resolved model feeds into.