12 KiB
PLAN — model-router wave 1-C: absolute tiers, availability fallback, derived phases (dispatch-ready)
Contract: .claude/tasks/contracts/2026-10-09-model-router-tiers-1237.md
Code: mods/model-router/hooks/register.ts (read in full) and register.test.ts.
API truth: mods/model-router/.claude-plugin/types/claude-code/index.d.ts
(TurnStepInput, TurnCompleteInput + TurnCompleteReason, SessionRateLimit,
classic.PostModelSwitch → PostModelSwitchHookInput, $.session.model,
$.model.classify, 'claude-code/testing'), .../claude-code-tools/index.d.ts.
Why (user, 2026-10-09)
- "Session model" phases assumed Fable. On a haiku session,
planat xhigh on haiku is wrong. Phases must name ABSOLUTE tiers. - When Fable has no credit left, routing must fall back (plan → opus xhigh).
- The user never types
/route: phases are derived (prompt wording, dispatch spans, skills) or declared by the model. - Reflection and planning always get the best available model.
Engine facts (read in the declarations today)
SessionRateLimit.kind∈ five_hour | seven_day | spend_limit: account windows, NOT per model → availability cannot be read from quotas.- A dead request:
turn.stepresultusage: null,stopReason: null;turn.completereason: 'error'(retries exhausted) or'refusal'(refused, no fallback model);'aborted'= the user interrupted. classic.PostModelSwitchfires on every main-model change withfrom_model,to_model,source∈ command|picker|sdk|auto|resume,context_tokens,prompt_cache_warm.turn.stepe.model= the id the engine resolved (session's or a fallback's);$.session.model()= the main loop's model as/modelshows.$.model.classify(text, labels, { model? })→ label | undefined, rejects on failure; default = the engine's small fast model.
Config (additions; everything else unchanged)
type Route = { model?: string; effort?: Level } // model: TIER name, alias or full id
type PromptRule = { pattern: string; phase: string; mode?: 'floor' | 'default' }
type Config = {
…existing…
tiers: Record<string, string[]> // tier → ordered alias preference
fallback: string[] // alias order, best first; = rank
cooldownMinutes: number // breaker hold
mainUpgrade: boolean // main loop may switch UP to a phase's tier
classifier: boolean // ask the small model when no rule matched
}
DEFAULT_CONFIG changes:
tiers: best['fable','opus','sonnet'], big['opus','fable','sonnet'], work['sonnet','opus'], cheap['haiku','sonnet'].fallback:['fable','opus','sonnet','haiku'].cooldownMinutes: 15.mainUpgrade: true.classifier: false.- phases: plan
{ model: 'best', effort: 'xhigh' }, reflect{ best, high }, orchestrate{ best, medium }, escalate{ best, max }, judge{ big, xhigh }, implement{ work, medium }, write{ work, medium }, verify{ work, xhigh }, explore{ work, medium }, mechanical{ cheap, low }. (Keep themodelfield name: a tier name is a model NAME the resolver understands; no newtierfield. AC3 grepstier: '…'?? NO: AC3 is written againstmodel: 'best'-style entries? → see AC3 note below.) - prompt:
[{ pattern: '\\bultrathink\\b', phase: 'escalate', mode: 'floor' }, { pattern: '\\b(plan|planifie|planning|brainstorm|architecture|con[cç]ois|design)\\b', phase: 'plan', mode: 'default' }, { pattern: '\\b(pourquoi|why|explique|explain|analyse|analyze|comprendre|understand|review|audit)\\b', phase: 'reflect', mode: 'default' }].modeabsent → 'default'. AC3 note for the executor: the contract's CHECK countstier: '(best|big|work|cheap)'in DEFAULT_CONFIG and refusesmodel: 'entries there. So the PHASE type gets an explicittier?: stringfield:type Route = { tier?: string; model?: string; effort?: Level }; a route resolvestierfirst, thenmodel. Default phases usetier:./route model=<x>keeps writingmodel(alias or id). The route tool keepsphase/effort/clearonly. Validation:tiersvalues = non-empty arrays ofmodelskeys (bad entries dropped, logged);fallback= array ofmodelskeys, deduplicated, non-empty (else default); phasetiermust be atierskey;cooldownMinutespositive integer;mode∈ floor|default.
Resolution (ONE resolver, used by spawn, main plan and texts)
function availableIn(st, aliases: string[]): string | undefined
// first alias whose full id is not down (st.down.get(id) > now → down)
function resolveModel(st, name: string): string
// tier name → availableIn(tiers[name]) ?? first alias → id
// alias → id (explicit: never skipped when down); full id → itself
function resolveRoute(st, route: Route): string | undefined
// route.tier ? resolveModel(st, route.tier) : route.model ? resolveModel(st, route.model) : undefined
function rank(st, id: string): number
// index in cfg.fallback of the alias whose id prefixes `id` (strip "[1m]"); unknown → fallback.length
now comes from $.clock.now() (read once per hook call that needs it).
Breaker (st.down: Map<string, number> full id → until ms; st.lastMainModel: string)
turn.complete: main (e.agentIdundefined) withreason∈ error|refusal →markDown(st, st.lastMainModel); agent with that reason →markDown(loop.model)(Loop gainsmodel: string, the model the engine reported at spawn,started.model).aborted/answer→ nothing.classic.PostModelSwitchwithe.source === 'auto'→markDown(e.from_model).markDownlogs ALWAYS (not only verbose):model-router: <id> unavailable until <HH:MM>; routing falls back./route reloadandsession.endclear the map.show()gets adown: <id> until <HH:MM>, …ordown: noneline.
Main-loop model decision (mainModel rewritten)
wanted = resolveRoute(st, (userMain ?? turnMain ?? turnFloor)?.route) // per-axis as today
cur = e.model
if cur is down and wanted is undefined → wanted = nextAvailable(st, cur) // fallback chain after cur's alias
if wanted undefined or sameRank(wanted, cur) → cur
if rank(wanted) < rank(cur) (better) → cfg.mainUpgrade ? wanted : cur
if rank(wanted) > rank(cur) (cheaper) → cfg.mainModelSwitch && windowOk ? wanted : cur
if cur is down and wanted defined → wanted (always: nothing to lose)
st.lastMainModel = plan.model at every main step. sameRank compares
aliases (so claude-fable-5-1 vs claude-fable-5-1[1m] never flips).
The log/status show → fallback when the breaker chose the model.
Spawn (spawnRoute / registerSpawn)
wanted = resolveRoute(st, route); explicit e.model still wins. Store
loop.model = started.model. (Explicit alias given by the caller while down:
left alone, explicit means explicit; note in the tool description.)
Derived phases (no user action)
D1. Dispatch push/pop: in the Agent tool.call hook, when e.agentId is
undefined (main) and st.turnMain?.source !== 'model' written AFTER the
dispatch… simpler rule: on a main Agent call, if st.resumeMain is
unset, st.resumeMain = st.turnMain ?? NONE and st.turnMain = { phase: 'orchestrate', route: phases.orchestrate, source: 'derived' }. When an
agent's turn.complete leaves st.loops empty AND st.turnMain?.source === 'derived' → st.turnMain = st.resumeMain (NONE → null), clear
resumeMain. A route call or skill load in between replaces turnMain
(source model/skill) so the pop is skipped and resumeMain cleared at
the next main turn.complete (endMainTurn clears both). Source type gains
'derived'.
D2. Prompt default rules (mode: 'default'): write turnMain = { phase, route, source: 'prompt' } (NOT the floor) — overridable by routes and
skills; floor rules unchanged. Mid-turn prompt with a default rule → only
pendingPrompt-like handling for FLOOR rules stays; a default rule typed
mid-turn is ignored (the running turn has its own routes).
D3. Classifier: when cfg.classifier and no rule matched and the prompt is
composer-origin and idle (no turnId): label = await $.model.classify(e.text.slice(0, MAX_PROMPT_SCAN), [...phaseNames, 'other']) in try/catch; a phase label → default route (source 'prompt');
anything else → nothing. Document the cost in the config comment.
Texts
routedText/mainNote/show: print the RESOLVED id and (fallback) when
the breaker skipped a better alias; (tier best → claude-fable-5-1).
Spinner/status unchanged shape.
Tests (register.test.ts; names must contain the contract's words)
Reuse the boot helper; full typed inputs; bottom hooks (agent.spawn,
turn.complete, classic.PostModelSwitch — read its input type for the
required fields; prompt.submit). Engine effort high in steps.
tier: a plan route upgrades a haiku session to fable at xhigh— route toolplan, step withmodel: 'claude-haiku-4-5-20251001'→ bottom seesclaude-fable-5-1andxhigh.downgrade: mechanical on fable keeps the model while the switch is off.fallback: an error turn on fable moves the next main step to opus— step on fable (sets lastMainModel),$.turn.complete({ reason: 'error', agentId undefined, … }), step on fable → bottom seesclaude-opus-5-5, effort unchanged; then/route reload→ step on fable stays fable.breaker: PostModelSwitch auto marks the old model down—$.classic.PostModelSwitch({ from_model: 'claude-fable-5-1', to_model: 'claude-opus-5-5', source: 'auto', … })→/route showlistsclaude-fable-5-1underdown:.spawn: Explore goes to opus while sonnet is down— mark sonnet down through an agent error turn (spawn Explore via bottom hook returning a1,$.turn.complete({ agentId: 'a1', reason: 'error' })), spawn again → bottome.model === 'claude-opus-5-5'.derived: a dispatch pushes orchestrate and pops the previous plan route— route toolplan,$.tool.call({ tool: 'Agent', … })with a bottom hook,/route showmain line showsderived orchestrate;$.turn.complete({ agentId: 'a1', reason: 'answer' })(loop registered via spawn) → main line showsmodel planagain.default rule: "planifie la migration" routes the turn to plan, a route call overrides.- Keep every B1
floortest and all earlier tests green (≥ 40 tests total).
Constraints
- ≤ 25 logic lines per function (extract helpers:
availableIn,rank,markDown,nextAvailable,decideMain,pushOrchestrate,popOrchestrate,applyDefaultRule,classifyPrompt), 80 chars/line, noany, state in the closure, fail-open.catchwithwarnOnceon every new hook (classic.PostModelSwitch), the route tool schema unchanged. - Do not touch: the hardening (caps,
safely, attestation), the Skill bridge, the floor slot semantics. - Verify: validate, the contract's tsc CHECK,
claude plugin test ., AC3/AC4 greps,gates.sh runon the contract.
Disposition
- honors BDR-115 and its amendment (one resolver, calling-loop writes, truthful texts, per-machine config); supersedes "a phase without model keeps the loop's model" (every default phase now names a tier).
- honors BDR-076 (dispatched judgment on opus first:
big= opus, fable, sonnet) and BDR-066 (execution on sonnet:work). - LRN-203: hooks still write full ids (the resolver's output).
- LRN-204: downgrade on main stays gated; upgrade accepted (quality over one cold-cache step).
- Deferred: repo agents' frontmatter pins cannot fall back (the mod does not see them in wave 1) → wave 2 moves them into the table with tiers.