Model routing
How one model is chosen for one turn: capability filters, tiers, a weighted score.
Every turn gets its own routing decision. A role's model policy is not the answer to "which model" — it is one of five inputs, and the other four are the stage's task class, an estimated complexity, the organisation's posture, and how much budget the run has left. This page describes the real mechanism, because the earlier version of it described something else.
What was wrong before, and what is true
The old documentation described routing as filtering the catalog by the role's tier band and then ordering by price, with quality meaning the strongest model inside the band. Four things about that are wrong, and wrong in ways that change how you would predict the system:
- Capability filtering is not tier-based. It happens first, and it filters on exclusions, tool calling, vision and context window. Price never sees a candidate that cannot do the job, and a tier never rescues one.
- The posture moves the target tier, not the sort order.
cheapaims at the policy'sminTier,balancedat the task-class or default tier, andqualityat exactly one tier up — clamped into the policy band. It is not "the strongest thing available". - A weighted score decides, not price. The formula is
0.45·fitness + 0.20·quality + 0.35·tierAffinity − costPressure·relativeCost − reliabilityPenalty + hintBonus. A model that is genuinely better at the work in front of it can win from a different tier. - A plugin rule can move the chosen tier. A rule that names a
tierpulls it to the front of the walk, and that tier is not clamped to the policy band. The README's claim that a rule "cannot move a turn to another tier" is contradicted by the code's own comment and by the code.
The pipeline, in order
A routing decision runs in ten steps. They are worth having in order, because several of the surprises are about when something happens rather than what it does.
1. Provider-key filtering, before the router is called
The router never sees the whole catalog. The engine hands it the provider registry's routable-model list, and in live mode that excludes every provider the installation cannot reach — one with no key and no keyless base URL. Routing to an unconfigured provider is not a fallback, it is a guaranteed failed turn, and with plugins able to declare providers it became easy to do by accident: declare a provider, forget the key, and every turn aimed at it dies. In mock mode nothing is excluded, because the mock adapter serves every provider, so the full catalog stays routable and the office is demonstrable without a single key. A model on an unconfigured provider is still catalogued so the UI can show it; only the routing pool leaves it out.
2. The target tier from the policy
Start with policy.byTaskClass[taskClass] if that class has an override, and policy.defaultTier otherwise. The engine computes exactly one task class per turn from the stage kind, so this is deterministic per stage — the twelve stage kinds map one-to-one onto twelve of the task classes: intake→intake, plan→planning, research→research, debate→debate, workshop→workshop, design→design, architect→architecture, build→coding, review→review, test→testing, integrate→ops, report→summarize. Every role's byTaskClass override is therefore addressable per stage: a role can be standard by default and strong at coding without saying anything about review.
3. Posture and escalation
This step is skipped entirely when policy.pin is true, which is the first meaning of that field: honour byTaskClass exactly and never escalate.
Otherwise the posture resolves the target, and then the escalation threshold gets a look:
cheap→ target becomespolicy.minTier. This is also forced regardless of posture when the run has less than $0.05 left, and the reason gains abudget nearly exhaustednote.balanced→ the target stays the task-class or default tier. This is the default posture.quality→ the target becomes one tier up from the policy tier (oneTierUp), which is a single step on the five-tier ladder and no more.- Then, if
escalateAtComplexityandescalateToare both set and the estimated complexity is greater than or equal to the threshold, the target becomesescalateTooutright — replacing whatever the posture wanted, not adding to it.
Note the order: escalation is applied after the posture and before the clamp. An escalateTo outside the policy's own band is therefore clamped like anything else.
4. Clamping into the policy band
The target is clamped into [minTier, maxTier] and, if the clamp changed anything, the reason says so: clamped to strong (policy bounds small..strong). The shipped defaults give every role minTier: 'small' and maxTier: 'max', with one exception — researcher overrides minTier to 'nano', because reading and summarising is exactly the work a nano model can do.
5. Capability filtering, with relaxation
Filtering happens on four things, none of which is a tier:
| Filter | Removes a model when |
|---|---|
| Exclusions | Its id is in the caller's exclude list, e.g. a provider observed to be down |
| Tool calling | The turn needs tools (it has at least one granted tool schema) and the model declares capabilities.tools: false |
| Vision | The request needs vision and the model does not declare it |
| Context window | A minimum context was requested and the model's window is smaller |
If that leaves nothing, the router relaxes rather than failing, in a fixed order, and records which relaxation it took in the decision reason:
no model met the capability requirements; dropped tool/vision requirements
no model met capability or context requirements; dropped both
no model satisfied the request; fell back to the full catalog
That last line is the important one: a turn whose requirements nothing can meet runs on the whole catalog rather than erroring, a deliberate choice of liveness over strictness, and the reason a turn can behave oddly while reporting no failure.
6. The walk
The router then builds a preference order over tiers, not a set of allowed models. A tier not on the walk is still scorable; it sits behind everything that is, with an affinity of zero. The order is:
- Tiers a plugin rule asked for, in the order the rules asked for them.
- The target tier.
- Every tier above the target, weakest first.
- Every tier below the target, strongest first.
Steps 3 and 4 preserve the old router's asymmetry exactly: reach for a bigger model before settling for a smaller one. They are also why a hint cannot quietly re-price the whole pipeline — a rule scoped to one task class contributes tiers at the front for that class only, and the rest of the order is untouched.
7. Scoring
Every surviving candidate gets a score, and the weights sum to one so a score reads as a 0..1 figure:
score = 0.45·fitness + 0.20·quality + 0.35·tierAffinity
− costPressure·relativeCost − reliabilityPenalty + hintBonus
| Term | Weight | What it is |
|---|---|---|
fitness | 0.45 | The model's blended fitness for this task class, 0..1. The headline signal. |
quality | 0.20 | Overall capability, 0..1. Matters even when the class is a perfect match. |
tierAffinity | 0.35 | 1 − 0.15 × position in the walk. Position 0 is fully preferred; each step down costs 15% of the term. |
relativeCost | × cost pressure | The model's compressed cost within the pool, 0..1 — log-compressed so a free local model and a frontier model do not collapse into two buckets. |
reliabilityPenalty | up to 0.30 | 0.30 × (1 − observed uptime), and only when uptime is known — an unmeasured provider is not penalised for being unmeasured. |
hintBonus | +0.08 / +0.04 / −0.12 | Additive adjustments from plugin routing rules. See below. |
Cost pressure is not a constant. It is 0.30 under cheap, 0.12 under balanced and 0.03 under quality — and it drops to zero when no model in the pool is rated at all. That degeneracy rule is deliberate: letting price pull a turn off the tier the policy asked for is only meaningful when something is known about what the money buys. With a completely unrated catalog every candidate gets the same population-mean priors, the quality terms cancel, and the outcome is the pre-scoring behaviour exactly — the policy's tier wins and the cheapest model in it is chosen. A fresh installation with no benchmark data therefore routes as the policy says and does not try to be clever.
8. Tie-breaks
Equal scores fall through to blended cost and then to the model id. Blended cost is the average of the per-million input and output rates, scaled to a per-1k basis — used for ordering and reporting, not for the actual cost of a call, which is computed from real token counts.
9. A pin, if the role has one
If policy.preferredModelId is set, the router looks that model up in the ranked pool. Three things can stop the pin, and each produces a note rather than a silent fallback:
- Not in the catalog —
pinned model 'x' is not in the catalog; chose normally instead. - Catalogued but filtered out or excluded —
pinned model 'x' is excluded or lacks a required capability; chose normally instead. - Outside the policy's own
minTier..maxTierbounds — the note spells out the tier and the bounds.
That third case is the design decision worth naming: the pin is honoured inside the policy's bounds, not above them. minTier and maxTier are the statement about how much model this role may have, and a pin that contradicts them is a mistake worth reporting rather than an instruction worth obeying that would make the bounds a lie.
10. Fallbacks and the record
The chosen model is only half the decision. The router also produces ordered fallbacks: same-tier models on other providers first, ordered by score rather than price so the first thing the engine reaches for when a provider dies is the next-best model for this work rather than merely the next-cheapest, then at most one model from one tier up. Every remaining candidate is recorded in considered with the honest reason it lost.
The five tiers
Tiers are coarse capability and cost bands, cheapest first: nano → small → standard → strong → max. They say "how much model does this work deserve" without naming a vendor, which keeps a policy sensible across a catalog that changes monthly. A policy is therefore a statement about a class of model, and the router picks the member of that class that fits the job.
ModelPolicy, field by field
| Field | Required | What it does |
|---|---|---|
defaultTier | yes | The tier this role normally runs at |
byTaskClass | no | Per-task-class tier overrides, e.g. { coding: 'strong' } |
minTier | yes | Hard floor; also the target under cheap |
maxTier | yes | Hard ceiling |
escalateAtComplexity | no | Complexity in 0..1 at which the turn is escalated |
escalateTo | no | The tier to escalate to when that threshold is crossed |
maxOutputTokens | no | Per-turn output cap; shipped default 4096, and 3072 on ui-designer and platform-engineer |
pin | no | Boolean. When true, skip posture and escalation entirely and honour byTaskClass exactly |
preferredModelId | no | A specific model to prefer, honoured only inside the policy's bounds |
These two fields are easy to confuse. pin is a boolean that switches off the two steps which would move the target tier; preferredModelId names a concrete model and is a preference with a bounds check. A policy can have either, both, or neither.
The shipped escalation thresholds
Escalation keeps a cheap default from being a cheap ceiling. The shipped chart sets thresholds on every role but two:
| Role | escalateAtComplexity | escalateTo |
|---|---|---|
ceo | 0.75 | max |
cto | 0.70 | max |
design-lead | 0.70 | strong |
qa-lead | 0.70 | strong |
backend-lead, frontend-lead, platform-engineer | 0.65 | strong |
frontend-dev-1, frontend-dev-2, backend-dev-1, backend-dev-2 | 0.60 | strong |
ui-designer, researcher | none | none |
The pattern is legible: engineers escalate earliest, because a hard implementation turn is where a weak model does the most damage, and the two roles whose work is inherently bounded do not escalate at all.
Complexity itself is a deterministic heuristic, not a model call. It starts from a per-stage baseline (intake 0.15, report 0.2, architect and integrate 0.6), adds a saturating term for text length, up to 0.22 for named hard problems such as deadlock, migrat, schema or cache invalidation, subtracts for explicitly trivial work such as typo or lint, adds for revision passes and filesystem work, and shifts slightly by seniority. It rounds to two decimals, so the same turn always produces the same number and the reason string stays stable.
A worked decision
Take the ceo role on a build stage. The stage maps to task class coding, and the shipped CEO policy carries byTaskClass: { coding: 'standard' }, so the target tier starts at standard. Posture is balanced so it stays there, and complexity comes out at 0.52 — below the CEO's 0.75 threshold — so nothing escalates. The policy is not pinned and has no preferred model, so step 7 decides.
The pool below is illustrative rather than a snapshot of the shipped catalog — what matters is the arithmetic, not the names. Blended cost is the average of the per-million input and output rates, scaled to a per-1k basis, and relative cost is that value log-compressed to 0..1 across the pool:
| Model | Tier | Tools | Fitness (coding) | Quality | Blended $/1k | Relative cost |
|---|---|---|---|---|---|---|
openai/gpt-5-nano | nano | yes | 0.40 | 0.55 | 0.3911 | 1.00 |
deepseek/deepseek-chat | small | yes | 0.55 | 0.72 | 0.0466 | 0.32 |
deepseek/deepseek-reasoner | standard | yes | 0.78 | 0.86 | 0.2780 | 0.93 |
anthropic/claude-sonnet-4-5 | strong | yes | 0.80 | 0.88 | 0.0554 | 0.34 |
openai/gpt-5-mini | standard | yes | 0.62 | 0.70 | 0.1125 | 0.51 |
local/qwen3-coder | standard | no | 0.75 | 0.62 | 0.6211 | — |
The turn's role was granted file tools, so requiresTools is true, which removes local/qwen3-coder before scoring — excellent coding fitness never reaches the ranking. The walk is standard, strong, max (empty here), small, nano, so the four tiers that occur have affinities 1.00, 0.85, 0.70 and 0.55. Cost pressure is 0.12: the posture is balanced and the pool does contain rated models.
| Model | Fitness | Quality | Affinity | Cost term | Score |
|---|---|---|---|---|---|
deepseek-reasoner (standard) | 0.78 | 0.86 | 1.00 | −0.112 | 0.804 |
claude-sonnet-4-5 (strong) | 0.80 | 0.88 | 0.85 | −0.041 | 0.780 |
gpt-5-nano (nano) | 0.40 | 0.55 | 0.55 | −0.120 | 0.512 |
gpt-5-mini (standard) | 0.62 | 0.70 | 1.00 | −0.061 | 0.680 |
deepseek-chat (small) | 0.55 | 0.72 | 0.70 | −0.038 | 0.545 |
Two things here are the point of the page. First, deepseek-reasoner wins on a standard-tier target while its nearest rival is a strong model with better fitness and quality — the 0.35 tier term keeps the policy meaningful without being decisive alone. Second, the winner is not the cheapest standard-tier model: gpt-5-mini costs less than half as much and loses by 0.124. Selection is not a price sort with extra steps.
The considered list that comes out of this is where the reasoning becomes auditable. The reason strings are literal, and they are what the routing panel shows:
| Candidate | Reason it lost |
|---|---|
local/qwen3-coder | lacks tool calling |
claude-sonnet-4-5 | scored 0.780 against the chosen 0.804 |
gpt-5-mini | scored 0.680 against the chosen 0.804 |
gpt-5-nano | scored 0.512 against the chosen 0.804 |
deepseek-chat | scored 0.545 against the chosen 0.804 |
| a model whose tier is off the walk | tier is not on this policy's walk |
| an equal-scoring, pricier model | tied on score; more expensive, or later by id |
The other rejection strings you will see are excluded, lacks vision, context window too small and a pin is in force for this role. The decision's overall reason field is a longer em-dash-joined clause list — the task-class mapping, any posture or clamp notes, a relaxation note if one fired, a pin note, chose <label> (provider/id), the score explanation, and a provenance clause such as quality from curated+learned.
Routing rules from plugins
A plugin may contribute routing rules. The real type has six fields beyond its id and description:
| Field | Effect |
|---|---|
id, description | Required, and the description is shown in the console |
taskClass | Scope the rule to one task class. Absent means every class. |
tier | Pull this tier to the front of the walk |
preferProviderIds | +0.04 to every model from that provider, within the rule's tier scope |
preferModelIds | +0.08 to that exact model |
avoidModelIds | −0.12 to that model |
Two caveats, because the documentation has got both of these wrong before and both are easy to misread from the field names:
A rule that names a tier does move the walk. The hinted tiers are placed at the front of the walk, ahead of the target the policy resolved, and that tier is not clamped to the policy's minTier/maxTier band. So a rule declaring tier: 'max' on a role bounded to standard makes a max-tier model the most-preferred tier for that task class. The claim in dev3d's own README that a rule "cannot move a turn to another tier" is false, and the router's source comment directly contradicts it. What a rule genuinely cannot do is add candidates: hints only reorder what already passed capability filtering, so a rule can never make the router pick a model that lacks tools or context.
avoidModelIds is a penalty, not an exclusion. It subtracts 0.12 — about a third of the fitness term — and reorders the candidate, but the model is still eligible and can still win if it is good enough or if the alternatives are worse. If you need a model to be genuinely unavailable, use the installation's disabled-model list or remove the provider's key, not a routing rule. The same is true of the exclusion list the engine passes in: that one is a real filter, applied before scoring.
Hints are applied in two places, which is worth knowing when one seems not to have worked. The model and provider preferences become score bonuses on the ranking, confined to the rule's tier (its own tier if it declares one, otherwise the policy's target tier); they are applied a second time to reorder the fallback list within the same tier. A bonus is sized below a tier step so it is a real pull inside a tier without outweighing one — with the exception already noted, which is the tier hint moving the whole walk.
Where the decision surfaces
A routing decision is not a log line you have to dig for. It reaches four surfaces:
| Surface | What it carries |
|---|---|
TurnRecord.route | The full decision — chosen provider and model, tier, task class, reason, fallbacks, every candidate considered, and the chosen model's quality, fitness and score |
The routing.decision event | Emitted before the turn starts, carrying the run id and turn id alongside the decision |
turn.started / turn.finished | The same record embedded in the turn, so a transcript replay carries it |
EmployeeState.lastRoute | A summary for the office UI: modelId, providerId, tier, reason, at |
That is the answer to the question the old documentation implied but never quite asked. Because the router records why it rejected every candidate, "was that turn worth a frontier model" is a question with an answer rather than a feeling: the score the winner won on, the score the runner-up lost on, and the clause that decided it.
Failover is a separate record from routing. When a provider fails and the engine retries against a fallback, the turn records which route actually served it and the ordered list of attempts, each shaped providerId/modelId: error — which is the difference between "routing is wrong" and "a provider was down".
Common mistakes and what to check
| Symptom | Likely cause |
|---|---|
| Posture changes appear to do nothing | No model in the pool is rated, so cost pressure is zero and the pool was chosen without quality data — or a preferredModelId pin is overriding selection |
| Every turn lands on one model | A preferredModelId is in force, or a plugin rule's tier is pulling that tier to the front of the walk |
quality did not obviously buy a better model | It moves the target one tier up and then clamps. If the task-class override already equals maxTier, the clamp undoes the move and the reason says clamped to … |
| A model is chosen that seems too weak | Read the reason: a relaxation clause means nothing met the request, and the turn fell back to a wider pool |
| A cheaper model with the same tier lost | Expected. The score is 45% fitness and 20% quality; price is a penalty scaled by posture, not the ordering key |
| A plugin's rule seems ignored | Check its taskClass. A rule with no taskClass applies to every class; a rule with the wrong one applies to none of the turns you are watching |
Two related pages fill in the edges: Projects, organisations and floors for where a run's spend is bounded, and Environment for DEV3D_ROUTING — which sets the installation default posture only, since the engine prefers the floor's own setting when one exists.
Where to go next
Linked from
Did this page answer your question?