Right now the dispatcher picks the next available slot regardless of
which model that slot runs and which task is next. Opus 4.7 (15× the
$/token of Haiku) routinely gets handed link-checker tasks while a
strategic-spec-design task waits for a free slot of any model. Build
a routing layer that, for each pending task, picks the *cheapest model
class capable of meeting the task's --requires bar* and reserves
expensive slots (Opus, GPT-5) for tasks above an EV threshold.
scidex/senate/model_router.py::route(task, available_slots) -> slot_id | None.ev_scorer.score_task, current per-model utilisation from quota_throttle.requires, breaking ties by lowest current utilisation. Override for tasks with EV > P90 or quest.priority >= 95 — they can claim a higher tier.model_routing_decisions(decision_id, task_id, chosen_slot_id, chosen_model, alternatives_json, reason, decided_at) so we can audit "why did Opus get a typo fix?".requires.services.next_task; if no slot fits, mark task await_slot not blocked./resources "Routing" panel shows last-24h matrix (model × layer) of decisions with $/decision.MODEL_TIER constant.ev_scorer.score_task as the EV input but cache per-task for 60 s.crosslink_emitter audit pattern, follow that.--dry-run mode that logs decisions but does not actually route, so we can validate before flipping the dispatcher.q-ri-quota-aware-throttle — provides per-model utilisation.orchestra/models.py for MODEL_CAPABILITIES/MODEL_TIER/MODEL_PROVIDER_COST;scidex/exchange/ev_scorer.py for scoring; scidex/senate/daily_budget.py for pricing patterns;api.py resources_dashboard for injection points.
scidex/senate/model_router.py:MODEL_TIER dict (tier 1=haiku/minimax/glm, tier 2=sonnet/codex, tier 3=opus/gpt-5)SlotInfo / TaskInfo / RoutingDecision dataclassesroute(task, available_slots, *, dry_run=False) -> slot_id | None with full decision logic_persist_decision() writes to model_routing_decisions audit table_ensure_table() idempotent CREATE TABLE IF NOT EXISTSget_routing_matrix(hours=24) for the dashboard API
score_task(task_id: str) -> float | None to scidex/exchange/ev_scorer.py/api/resources/routing-matrix JSON endpoint to api.py/resources dashboard (JS fetch + table render)scidex/senate/test_model_router.py with 14 tests — all pass:route(task, available_slots) -> slot_id | Nonemodel_routing_decisions table (created at runtime on first use)/resources Routing panel