8.3 KiB
CONTRACT — model-router-tiers (wave 1-C: absolute tiers, availability fallback, derived phases)
- date: 2026-10-09 | flow: feat | branch: feature/model-router-mod
- status: active
REQUEST (verbatim — IMMUTABLE)
User (fr): "j'aimerais ne pas avoir a reflechir a tout ca, donc ne pas lancer les /route moi meme par exemple. On vois le reflect, orchestratm escalate et plan session (fable) par du preincipe que la sessino est sur fable de base ? Si on est sur haiku j'aimerias que ca fonctionne aussi avec les models adapte. et d'ailleurs que se passe til quand on a plus de credit fable, cela passe sur opus xhigh ? il faut se fallback. ET surtout oui avec un systeme adaptatif et pour la reflexion pour trouver une solution ou des idees, il faut le meilleur, pour en faire le plan en se basant sur cette reflexion. Bref un systeme logique et optimise. /ultrathink . ensuite je reload puginm tu test, on commit puis on passe a la vague 2"
CLARIFICATIONS
Q: "session" phases / A: no phase keeps "the session model" any more: every phase names a TIER (best, big, work, cheap), an ordered list of aliases; the first AVAILABLE alias wins. plan/reflect/orchestrate/escalate → best (fable, then opus, then sonnet). A haiku session asked to plan runs the plan on fable. [user: "si on est sur haiku j'aimerais que ca fonctionne aussi avec les models adaptés"]
Q: what is "available" / A: the mod cannot read per-model quota (rateLimits are account windows: five_hour, seven_day). Availability = a circuit breaker: a model is DOWN for cooldownMinutes (default 15) after a main or agent turn ends with reason: 'error' or 'refusal' on it, or when the engine itself switched away from it (classic.PostModelSwitch, source: 'auto'). A down model is skipped in every tier and in the fallback chain; /route show lists down models with their reset time; /route reload clears the breaker. [orchestrator — derived from the engine's declarations]
Q: no credit left on fable / A: main loop on a down model → next available alias of the fallback chain (fable, opus, sonnet, haiku), effort unchanged (plan stays xhigh → "opus xhigh"), always allowed (a down model yields nothing), logged and shown. [user: "il faut se fallback"]
Q: main-loop model moves / A: UPGRADE (phase tier ranks above the current model) allowed by default (mainUpgrade: true); DOWNGRADE (cheaper model) still gated by mainModelSwitch (default false: one cold-cache step per switch, and haiku's window may not fit); same rank → no switch. [orchestrator — cost/quality trade-off, stated to the user]
Q: no /route typed by the user / A: phases come from (1) prompt rules at turn start (keyword → phase as the turn's DEFAULT route, source 'prompt', overridable; ultrathink stays a FLOOR), (2) the model's own route calls and the skills table, (3) derived: an Agent dispatch from main pushes orchestrate and the previous route is restored when the last live agent ends, (4) optional classifier (classifier: false by default) that asks the engine's small model for a phase label when no rule matched. [user: "ne pas lancer les /route moi-même"]
Q: default prompt rules / A: floor: \bultrathink\b → escalate. Defaults: \b(plan|planifie|planning|brainstorm|architecture|con[cç]ois|design)\b → plan; \b(pourquoi|why|explique|explain|analyse|analyze|comprendre|understand|review|audit)\b → reflect. No default rule lowers a turn (no mechanical rule): lowering is explicit (route tool, skills). [orchestrator — conservative defaults, user-editable in the override]
Q: typed /effort-<l> / A: unchanged (floor + default of the turn, B1).
ACCEPTANCE CRITERIA
- Suite green:
claude plugin testpasses with at least 40test(calls;claude plugin validatepasses with no warning; no line over 80 chars; noanytype. CHECK: cd mods/model-router && out=(claude plugin test . 2>&1); rc=?; echo "$out" | tail -n 3; [ $rc -eq 0 ] && [ "$(grep -cE '^\stest(' hooks/register.test.ts)" -ge 40 ] && v=$(claude plugin validate . 2>&1) && echo "$v" | grep -q 'Validation passed' && ! echo "$v" | grep -qi 'warning' && ! grep -nE '.{81,}' hooks/register.ts hooks/register.test.ts && ! grep -nE ':\sany\b||as any\b' hooks/register.ts && echo TIERS-SUITE-OK EXPECT: TIERS-SUITE-OK EVIDENCE: pending - Type-check clean against this build's declarations. CHECK: T=/Users/b.chanot/Documents/claude/mods/model-router/.claude-plugin/types; W=$(mktemp -d) && printf '{"compilerOptions":{"target":"es2023","lib":["es2023"],"types":[],"module":"esnext","moduleResolution":"bundler","strict":true,"noUncheckedIndexedAccess":true,"noEmit":true,"skipLibCheck":true,"jsx":"react","jsxFactory":"h","jsxFragmentFactory":"Fragment"},"include":["%s/claude-code/index.d.ts","%s/claude-code-tools/index.d.ts","%s/hooks"]}' "$T" "$T" "$PWD/mods/model-router" > "$W/tsconfig.json" && (cd "$W" && npx --yes -p typescript@5 tsc -p tsconfig.json) && echo TSC-OK EXPECT: TSC-OK EVIDENCE: pending
- Tiers in the config:
tiers(best/big/work/cheap),fallback,cooldownMinutes,mainUpgrade,classifierexist in DEFAULT_CONFIG; every default phase names a tier, none a bare model;/route showprints the resolved model of each phase and adown:line. CHECK: cd mods/model-router/hooks && grep -q "tiers:" register.ts && grep -q "fallback:" register.ts && grep -q "cooldownMinutes" register.ts && grep -q "mainUpgrade" register.ts && grep -q "classifier" register.ts && [ "$(awk '/^const DEFAULT_CONFIG/,/^}/' register.ts | grep -cE "tier: '(best|big|work|cheap)'")" -ge 10 ] && ! awk '/^const DEFAULT_CONFIG/,/^}/' register.ts | grep -qE "^\s+[a-z]+: { model: '" && grep -q "down:" register.ts && echo TIERS-CONFIG-OK EXPECT: TIERS-CONFIG-OK EVIDENCE: pending - Tests prove (names contain the quoted word):
tier— aplanroute on a session modelclaude-haiku-4-5-20251001makes the main step run onclaude-fable-5-1at xhigh (upgrade, default on);downgrade— amechanicalroute on a fable session leaves the model unchanged whilemainModelSwitchis off;fallback— after a mainturn.completewithreason: 'error'on fable, the next main step runs onclaude-opus-5-5with its effort unchanged, and after/route reloadfable is used again;breaker— aclassic.PostModelSwitchwithsource: 'auto'from fable marks fable down and/route showlists it;spawn—Explorespawns onclaude-opus-5-5while sonnet is down;derived— an Agent tool call from main setsorchestrateand the previous route (plan) is back after the last agent'sturn.complete;default rule— a prompt "planifie la migration" sets theplanroute as the turn default and a laterroutetool call overrides it;floortests from B1 still pass. CHECK: cd mods/model-router/hooks && for w in tier downgrade fallback breaker spawn derived "default rule"; do grep -qE "test('[^']*$w" register.test.ts || { echo "missing test: $w"; exit 1; }; done && echo TIERS-TESTS-OK EXPECT: TIERS-TESTS-OK EVIDENCE: pending - Judged by reading: ONE resolver (
resolveModel) turns a tier name, an alias or a full id into the first AVAILABLE full id (tier → list → skip down → alias → id; a bare alias or id passes through even when down, since explicit means explicit); rank = position infallback; the main-loop decision is: route model wanted → upgrade allowed bymainUpgrade, downgrade gated bymainModelSwitch+ window, same rank → keep; no route and current model down → next available infallback; the breaker is fed only byturn.completereasoninerror|refusal(main: the last main step's model; agent: the loop's spawn model) and byclassic.PostModelSwitchsource: 'auto'(from_model), never by an aborted turn; the derivedorchestratepush/pop never overrides a route the model declared after the dispatch; prompt default rules writeturnMain(source 'prompt'), floor rules writeturnFloor; the classifier runs only whenclassifieris true and no rule matched, through$.model.classify, labels limited to the phase names plusother, any failure → no route; explicit Agent params still win; agent model fixed at spawn; every B1/1-A criterion still holds; no function over 25 logic lines; truthful texts name the resolved model and say "fallback" when the breaker chose it.
FILE SCOPE
mods/model-router/hooks/register.ts · mods/model-router/hooks/register.test.ts