Skip to main content
Not every query needs the same model. A frontier-class model is expensive and often overkill for an easy question, and a cheap model quietly fails on a hard one. The usual answer is to pick one model for everything, or to hand-write routing rules. BenchGen Router Lite takes a different approach, similar in spirit to Sakana AI’s work on model routing: train a small, dedicated router that learns, from measured evidence rather than guesses, which model in a pool is the right one for a given query. The router itself never generates an answer. It looks at a query, picks the best pool member for it, and that pool member’s real reply is what comes back. This guide tells the complete story of how BenchGen Router Lite was built: the dataset it learned from, how it was trained, and how it was benchmarked. On Router Fidelity Benchmark, the trained router scored 82.6%, more accurate than calling its own strongest pool member for every single question, at roughly 3.1x lower cost. The full breakdown is in Benchmark below.
Diagram of one query moving through BenchGen Router Lite: the frozen backbone embeds it, the router head picks one pool member, here open_mid, and only that model's real reply is returned

One query moving through BenchGen Router Lite: the frozen backbone embeds it, the head picks one pool member (here open_mid), and only that model's real reply comes back. No text is ever generated by the head itself.

Everything below, the dataset, the pipeline that built it, and the trained head, is open source, hosted outside these docs. Here is all of it in one place before the story starts:

Source and pipeline

The full five-stage generation pipeline, tests, and reports, as a runnable repository.

Rewards dataset

46 tasks, 5 agents, 3 repetitions. Also on Hugging Face.

Task pool

1,110 tasks with prompt text. Also on Hugging Face.

Trained head

benchgen-router-lite. Also on Hugging Face.

Router Fidelity Benchmark

46 in-distribution tasks. 82.6% accuracy, roughly 3.1x cheaper than its own strongest pool member called alone. Full results in Benchmark below.
This guide refers to the trained head by its stable model-page name, benchgen-router-lite. The platform itself registers each training run under its own generated id, which changes on every retrain, so an id shown on the benchgen-router-lite model page or on the benchmark’s leaderboard may not match run to run; that page’s URL is what stays stable. Opening an entry from the leaderboard also requires signing in, since individual evaluation pages aren’t public.

1. Build the dataset

A router has nothing to learn from until you can prove that different models are actually better at different things. That proof is a reward matrix: for every task, how well each candidate model actually scored on it, measured rather than assumed. BenchGen Router Lite trains on two paired datasets published from a single pilot collection run. The join key is task_id, shaped {source}/{split}/{index}. Training the router head means loading both datasets and matching rows on that field before anything gets embedded.

The pipeline, end to end

Both datasets come out of one ordered pipeline of five stages, run in sequence. Each writes a data file into data/ and a report into reports/, and each refuses to run ahead of its own gate, so a broken pool cannot quietly become a published dataset.
Shared settings live in configs/, never inline in a script: the call protocol and pool in agents.v1.json, the seed, repetitions and sampling plan in collection.v1.json, the gate thresholds in gates.v1.json. Every report carries a SHA-256 digest of its inputs and no timestamps, so stages 4 and 5 rebuild their output byte for byte from committed data with no network access and no API key. That is the cheapest possible regression test:
Stage 3 accepts --dry-run, which prints the call count and an upper bound on cost and makes zero calls. Stage 5 accepts --push, and uploads privately unless --public is also given.

Stage 1: prove the pool answers

Every model was reached through OpenRouter, so the pool could mix closed-style and open-weight models behind one API and one billing account. Candidates were picked from the live OpenRouter catalogue, skipping slug variants that would make the measurement meaningless: :free is rate limited and silently truncates, :batch is asynchronous, and nitro, floor, online and extended all change routing. Any of those makes a reward column measure the variant rather than the model. A dead or misbehaving slug is worse than a weak model, because it fills a whole reward column with silent zeros and teaches the router to avoid a model that was never actually broken. So before a cent is spent, every active agent gets 21 live calls: 20 against a trivial prompt, plus one prompt that cannot be satisfied inside max_tokens.
pipeline/01_verify_pool.py
A pass stamps verified_at in configs/agents.v1.json, and Stage 3 raises rather than start against a slug that never got one. The five models that passed split into two roles inside the pool: The three Frontier slots are the pool’s strong anchors, the models a naive “always use one model” strategy would pick, standing in for closed, proprietary frontier APIs on a budget that favored strong open-weight models available on OpenRouter. The Mid-tier slot is the deliberately weakest agent in the pool, included specifically to see if it is ever uniquely best on cheap questions, and the cheap reasoning slot is the cheapest reasoning-capable model in the catalogue, from a different vendor family so its failures do not correlate with the frontier slots. This composition mirrors the coordinator design in Sakana AI’s Trinity paper (arXiv:2512.04695): a router only has something to learn if no single agent dominates everywhere. A pool of near-identical strong models gives a router nothing to route between; a pool with a genuinely weaker, cheaper option gives it a real decision to make. Every agent is called under identical settings, so a score difference reflects the model and not the prompt conditions. They live in configs/agents.v1.json under protocol, so the whole pool moves together or not at all:

Stage 2: build the task dataset

02_build_task_dataset.py runs one deterministic chain: load, map, dedupe, balance, split. It is seeded from configs/collection.v1.json and safe to re-run, so the same config always produces a byte-identical data/tasks.v1.jsonl.
pipeline/02_build_task_dataset.py
What build_pool does, step by step:

Load and map

One adapter per upstream source, all normalizing to the same Task schema. Column names drift between mirrors of the same benchmark, so each adapter accepts a short list of known spellings instead of guessing one and silently yielding zero rows.

Dedupe

A SHA-1 fingerprint over normalized prompt text, first occurrence kept. Inputs are sorted by task_id first, so which copy survives is deterministic. This build dropped 0 duplicates.

Balance

No domain above max_domain_share. A seeded binary search finds the largest per-domain cap that still satisfies the constraint, which keeps the most data. It cost 30 knowledge rows and 30 math rows.

Split

Stratified by (domain, difficulty) at 60/20/20, with a separate seeded shuffle per stratum, then a leakage assertion that raises if any task_id landed in two splits.
Every row also carries full provenance (dataset, config, split, index, licence, and a redistributable flag), and difficulty comes from the source where it has one. MATH500 levels 1 to 5 map onto easy, easy, medium, hard, hard; sources without a level use their own default. The balance step is the one place where the config asks for something arithmetically impossible, and the code says so rather than quietly failing its own gate:
taskpool/balance.py
Gate 2 then checks the finished pool: every domain inside the effective cap, every row with a non-empty answer and a named grader, and no empty split. The result is a normalized set of prompts drawn from eight public benchmark sources: Domain, difficulty, and split are balanced deliberately, not incidentally:

Domain

Knowledge: 370 (33.3%)
Math: 370 (33.3%)
Reasoning: 370 (33.3%)

Difficulty

Medium: 504 (45.4%)
Hard: 427 (38.5%)
Easy: 179 (16.1%)

Split

Train: 648 (58.4%)
Test: 245 (22.1%)
Validation: 217 (19.5%)
All 1,110 tasks are published as the paired tasks dataset. Only the 46 tasks described below currently carry reward labels; the rest of the pool exists so future collection rounds, on a wider agent pool or a bigger budget, can reuse the same balanced task set instead of rebuilding it.

Stage 3: collect the reward dataset

This is the only stage that spends money, so it starts by refusing to. --dry-run prints the exact call count and an upper bound on cost, then exits without making a single call:
The 60 tasks are a seeded draw of 20 per domain from the task pool. Seeding per domain rather than globally means adding a domain later does not reshuffle the ones already collected:
pipeline/03_collect_rewards.py
The real run then calls every active agent 3 times on each task. Every agent sees byte-identical messages for a given task, a fixed system prompt plus the task prompt, with nothing agent-specific anywhere in the payload:
pipeline/03_collect_rewards.py
Those two exception handlers and the resume flag are what make the run safe to leave unattended: That last guard is not hypothetical. It fired during collection, when one candidate returned 14,433 completion tokens against the 4,096 cap. It had passed pre-flight only because its length probe happened to answer in 640 tokens, which is not evidence of compliance. It was retired on the spot, its pool index kept and never reused.

Grading, and how a reward row is built

Each reply is graded by a named grader that the task row references by name, so grading logic lives in code and never in the data. Extraction runs from the most explicit signal to the weakest fallback: a tagged answer, brace-balanced \boxed{...} (a plain regex breaks on nested braces like \frac), a labelled answer, then a last-number fallback that strips markup first. An empty response is never scored correct, and empties are counted separately from provider errors so a broken transport is never mistaken for a weak model. A row is then derived from the calls, never trusted from the file:
schemas/rewards.py
mean_reward[i] is agent agent_order[i]’s correct calls divided by repetitions, and best_agents and is_tie are recomputed by that validator every single time a record loads. Ties stay explicit rather than collapsing to a single winner, because on easy questions several agents are genuinely equal and pretending otherwise teaches a router a coin flip:
The pilot scored 60 tasks x 5 agents x 3 repetitions = 900 graded calls across 8 source datasets, with zero call errors, for a total of $0.0826 against that $0.17 ceiling. Every call is logged with its real, provider-reported cost, so the bill is auditable per agent, not just as one total: That is $0.000092 per call, roughly $0.0014 per task (5 agents x 3 repetitions). The router head itself adds nothing on top of this number: it trains against the static matrix above with sep-CMA-ES and makes zero further API calls, so $0.0826 is the entire cost of the data the router head was evolved against. Stage 1’s pre-flight probes, 21 short calls per candidate agent against a five-word prompt, add a further near-free cost, well under a cent across the whole pool.

Stage 4: decide whether this pool deserves a router at all

A routing dataset is only useful if no single agent dominates. If one agent is best almost everywhere, the optimal router is “always pick that agent” and no amount of training helps. Stage 4 measures that before anything gets trained. It reads only data/ and configs/, needs no network and no API key, and exits non-zero when the gate says STOP so CI can enforce it:
pipeline/04_gate_and_baselines.py
The thresholds live in configs/gates.v1.json, each with a continue level and a stop level: The pilot’s verdict is STOP, and the pipeline reports it rather than burying it. Three metrics block:
  • Unique best agent on only 11.7% of tasks. On most tasks either everyone gets it right or nobody does, and neither case carries any routing signal.
  • The weakest agent is never uniquely best. An agent at zero here is dead weight in the action space: nothing is lost by removing it from the pool entirely.
  • A 15.6% empty-response rate on the worst agent. High enough to depress that agent’s measured reward for a transport reason rather than a capability one.
The gate exists because of an earlier attempt on this problem, which collected 2,700 real calls and trained two heads before anyone noticed that 75.3% of its tasks scored every agent identically. The router it produced exactly matched “always pick one specific model”. The pilot here is better than that on every axis and still does not clear the bar, which is precisely why this release is a pilot and the head trained on it is Lite. Stage 4’s second output is the baselines, on the held-out test split, because a routing dataset that reports only the winner’s score invites exactly the mistake above: The gap between the best fixed agent and the oracle, 0.1026, is the routing headroom: the most a trained router could possibly gain over always calling frontier_a. That is the bar the trained router is measured against in Benchmark below.

Stage 5: publish only what is safe to publish

The last stage drops two things. The rlpr source goes first: its answers are free-form prose and tables, and the normalized string-match grader used here scores genuinely correct answers as wrong. A label that cannot be trusted is worse than no label, so all 14 of its tasks are excluded, leaving 46 tasks x 5 agents x 3 repetitions = 690 graded calls in the published rewards dataset. Prompt text and raw completions go second, and two independent safeguards enforce it. The public row type has no prompt or completion field at all and forbids extra fields, so one cannot be added by accident, and the finished export is then scanned before a single byte is uploaded:
pipeline/05_publish.py
Alongside the rewards goes an agent manifest naming which model produced each reward column, at what price, and when it was last verified. Without it the reward vectors are just numbers. Uploads are private by default; making a release public takes an explicit second flag.
Nothing published contains question text or raw model completions. Upstream licences differ per source and provider terms on republishing outputs were not cleared for every agent, so the reward signal (correctness, ties, empties, errors) is published without either. The task_id lets a consumer rehydrate the prompt from upstream themselves.

2. Train the router head

BenchGen Router Lite does not fine-tune a model, and it does not train a LoRA adapter on top of one. What gets trained is a router head: a small classifier, a few thousand parameters, that sits on top of a frozen backbone and learns which pool member to send each query to. The backbone never updates. Only the head does. The head is trained with separable CMA-ES against the static reward matrix from the dataset above, not by calling any pool model during training. Every (task, agent) reward is already in the dataset, so a full training run makes zero API calls and costs nothing beyond the embedding pass.
There are two ways to run this training. The walkthrough below is the platform path: point the Train tab at the dataset and click through. The same training worker is also published as a standalone script, train/train_router_head.py, in the benchgen-router-lite-dataset repo. It is not a rewrite: the embedding step, the head shapes, and the sep-CMA-ES loop below are the platform’s own code, with only the storage boundary changed to write local files instead of uploading to platform-internal storage. See Run it yourself at the end of this section.

1. Start a new training run and pick Head

In the Train tab, click + New Training, then pick Head on the training method toggle (“Learn from past agent scores”). Point Base Model at the frozen backbone (this run used Qwen/Qwen3-1.7B) and Dataset at the rewards dataset, benchgen/router-pilot.
Training method set to Head, with Qwen3-1.7B as the base model and router-pilot as the dataset

The New Training form with Head selected, Qwen/Qwen3-1.7B as the base model, and router-pilot as the dataset

2. Fill in the head-specific fields

Add the Tasks dataset, benchgen/router-pilot-tasks, so prompts can be joined and embedded. Set Held-out split value to test, the split the dataset’s own reported baselines use, and leave Head hidden dimension at 0 for a pure linear head. This run used the defaults for the rest: sigma0 0.5, 60 generations, automatic population size, and seed 42, with the CUDA embedding device.
The Head-specific fields: tasks dataset, held-out split, head hidden dimension, and CMA-ES hyperparameters

The Head-specific fields: tasks dataset, held-out split value, head hidden dimension, and the sep-CMA-ES hyperparameters (sigma0, generations, population, seed)

3. Submit and watch it train

Click Start Training. The logs confirm exactly what is loading before anything else happens:
Training logs showing the reward dataset, tasks dataset, and five-agent pool loading

Training logs right after submit: the rewards dataset, the tasks dataset, and the five-agent pool all loading before embedding starts

4. CMA-ES evolves the head

The backbone embeds each prompt once, splits into 35 train rows and 11 test rows, and then a 10,245-parameter head is evolved against the training rows:
Training progress and logs showing sep-CMA-ES generations and the best training reward so far

Training Progress at 100% alongside the raw logs: 35 train rows, 11 test rows, a 10,245-parameter head, and the best training reward climbing across generations

That 10245 is not asserted, it falls out of a formula against the actual embedding dimension and pool size, and a “head” is nothing more than the unflattened weights of a linear layer, or a one-hidden-layer MLP if head_hidden_dim is set above 0:
train/train_router_head.py
2048 x 5 + 5 = 10245: the embedding dimension times the pool size, plus one bias per agent. Every candidate CMA-ES proposes is one of these vectors, scored by unflattening it into a head, running every training row’s embedding through it, and averaging the reward at the argmax pick:
train/train_router_head.py
There is no gradient anywhere in this loop. es.ask() proposes a population of candidate weight vectors from a search distribution, es.tell() updates that distribution toward whichever candidates scored best, and nothing here ever needs to know how to differentiate through an argmax pick, which a router head’s output makes gradient descent awkward for in the first place. This whole run, embedding included, finished in 6 seconds on a single auto-selected GPU. That best_reward_so_far number is the head’s fit to the 35 training rows as CMA-ES optimizes, not a held-out score. What the trained head actually generalizes to is what Benchmark below measures.

5. Register the head

A completed job’s Use In Benchmarks panel offers Register as Model. Click it, and the head becomes selectable anywhere BenchGen lets you pick a model, carrying a Head badge and a stable name (benchgen-router-lite for this run) so it is never confused with a regular fine-tuned model. There is no merge or export step, since a router head has no adapter weights to fold into a base model.
A completed and registered router head, registered as benchgen-router-lite

The completed job, registered and ready as benchgen-router-lite, selectable anywhere BenchGen lets you pick a model

Run it yourself

With no argument it reproduces this exact run against benchgen/router-pilot and benchgen/router-pilot-tasks: the same backbone, sigma0, generation count, and seed as above. Before this script shipped, its data-loading and joining path was run against the real published dataset to confirm it: 46 joined rows, 0 missing, 35 train and 11 test after the split, embedding dimension 2048, and head_param_count(2048, 0, 5) == 10245, matching every number on this page.

train/ on GitHub

The full trainer, its example config, and a two-way comparison with the platform path.

Trained head

The benchgen-router-lite head this platform run and this script both produce.
Writes head_weights.npy and manifest.json to a local folder. It does not register a model, serve one, or make any API calls; the Register as Model step above and the routing gateway behind it are platform-only, and are what Benchmark below actually exercises.

3. Benchmark

A router head’s own output is a routing decision, not an answer. Running it through a benchmark scores the router and its pool together, not the head in isolation, since every response a user sees comes from whichever pool member the head picked. BenchGen Router Lite was evaluated against Router Fidelity Benchmark, a named, published BenchGen environment built specifically to answer one question: does a router model actually route, or does it collapse to one agent? 46 English math, knowledge, and reasoning tasks (MATH500, MMLU, MMLU-Pro, ARC-Challenge, GSM8K, AIME2025), drawn from the exact same distribution the head’s own reward matrix was trained and held out against, joined from benchgen/router-pilot and benchgen/router-pilot-tasks by task_id. Model connection is env-var-first (RPE_* / MODEL_*, injected by the platform, no model.py needed), and because the model under test is a router, the results page adds a routing report: which pool member (and the real underlying model it resolved to) answered each question, alongside the overall routing distribution.
Router Fidelity Benchmark overview page describing the 46-task in-distribution benchmark, its scoring rules, and its routing and cost reporting

The Router Fidelity Benchmark overview: 46 in-distribution tasks, env-var-first model connection, and a routing report for router models

This is deliberately an in-distribution test. A router head embeds each query with a frozen backbone and picks a pool member based on where that embedding falls relative to what it saw during training; evaluate it on a wildly different distribution and the embedding lands somewhere the classifier never learned to discriminate, and its pick becomes arbitrary rather than a genuine routing decision. Router Fidelity Benchmark’s tasks come from the exact sources the router trained on, so a run here is a real fidelity check: does the routing decision hold up on the data it was actually built for?

Running the evaluation

From the benchmark’s Evaluate tab, the registered head shows up under Platform Models alongside every other trained model, carrying its Head badge. Selecting it auto-fills the sampling parameters from the model, and the Advanced panel exposes RPE_MAX_QUESTIONS for a quick debugging run against the first few questions before committing to the full 46. Run Evaluation starts the same round trip described above: each question gets embedded, the head picks a pool member, and that pool member answers for real.
Selecting the registered benchgen-router-lite head under Platform Models on the Router Fidelity Benchmark, with the Advanced environment variable panel expanded

Selecting benchgen-router-lite under Platform Models on Router Fidelity Benchmark, with the env-var Advanced panel showing RPE_MAX_QUESTIONS for a quick debugging run

The Router Fidelity Benchmark evaluation running live, with status logs streaming step-by-step progress through the 46 questions

The evaluation running live, streaming step-by-step progress through the 46 questions

Watching the questions arrive at the pool

Because the router never generates text itself, every question it routes turns into a real API call to the selected pool member. Those calls show up in the OpenRouter dashboard as the run proceeds, which is the clearest way to confirm the routing is genuinely happening rather than being simulated. The generations log lists each call with the model that received it, the provider that served it, token counts, and cost. The pool members reachable during this run appear here by name: Claude Sonnet 5, Ling-3.0-flash, and DeepSeek V4 Flash 0423, frontier_a’s current pool assignment alongside the other two agents the head actually reached for.
OpenRouter generations log showing Claude Sonnet 5, Ling-3.0-flash, and DeepSeek V4 Flash 0423 calls with provider, input and output tokens, and cost

The OpenRouter generations log during the Router Fidelity Benchmark run: Claude Sonnet 5, Ling-3.0-flash, and DeepSeek V4 Flash calls, each with provider, tokens, and cost

Opening a single call shows the exact prompt the pool member received: the benchmark question and its answer-format instruction, passed through untouched by the router. This one, task_id math500/test/000121, “Solve for x, put the final answer inside \boxed{},” was routed to frontier_a and answered by anthropic/claude-sonnet-5-20260630.
OpenRouter prompt view showing a math500 question routed to claude-sonnet-5, with the boxed-answer format instruction

One routed question as claude-sonnet-5 received it: the benchmark's own math prompt and boxed-answer instruction, passed through untouched

The generation details confirm which key issued the call, benchgen-router-lite, along with the routing and generation timings, throughput, and the exact input and output token counts for that single question.
OpenRouter generation details showing the benchgen-router-lite API key, claude-sonnet-5 as the model, provider routing timings, throughput, and token counts

Generation details for one routed call to claude-sonnet-5, issued by the benchgen-router-lite key, with routing timings, throughput, and token counts

Results

The pool member behind frontier_a has moved on from data-collection time. The reward matrix in Build the dataset above was collected against openai/gpt-oss-120b. This run, and the table below, route to the pool’s current frontier_a, anthropic/claude-sonnet-5. The agent_order identifiers are stable; the real model each one resolves to is a serving-time mapping and can change independently of the training data, same as swapping any other agent in the pool.
Evaluating benchgen-router-lite here, instead of collapsing to a single agent, the head scored 82.6% (38 of 46 correct) and spread its picks across three different pool members:
Router Fidelity Benchmark results showing 82.6% accuracy, cost and token totals, difficulty and domain breakdowns, and the routing distribution

The completed Router Fidelity Benchmark run: 82.6% overall, difficulty and domain breakdowns, and the routing distribution across three pool members

That spread of three different agents actually receiving traffic, rather than one, is what a router doing its job is supposed to look like: real evidence that the routing decision holds up on the data it was built for, not a collapse to whichever agent happens to be cheapest. The run also reports real cost: $0.0286 total, $0.000622 per task, across 14,483 tokens. What that number is actually worth only shows up next to a baseline, which is exactly what Router vs. a single frontier model further down measures.

Router vs. a single frontier model

The Cost KPI on Router Fidelity Benchmark exists precisely so a router’s cost can sit on the same leaderboard as a single fixed model and actually be compared. The baseline here is not an arbitrary outside model: anthropic/claude-sonnet-5 is frontier_a, the exact model the router above picked 36 of 46 times. Calling it directly for every one of the same 46 questions, no routing at all, answers a precise question: what does the pool’s own strongest model cost when it answers everything, against what it costs when the router only reaches for it when a question actually calls for it?
Four paired bar charts comparing BenchGen Router Lite against claude-sonnet-5 called alone on the same 46 questions: accuracy 82.6% vs 80.4%, total cost $0.0286 vs $0.0889, average cost per task $0.000622 vs $0.001932, total tokens 14,483 vs 16,021

Accuracy, total cost, average cost per task, and total tokens, side by side: the router matches or beats the single model on everything except total tokens, where it is close, at roughly a third of the cost

Both numbers move the same direction: the router is more accurate and roughly 3.1x cheaper, $0.0889 against $0.0286 in total cost, on the identical 46 questions. Total tokens are close, 14,483 against 16,021, only about 10% fewer for the router, so the savings are not coming from shorter answers. They come from where the questions get sent: three of the five pool models cost a fraction of a frontier model per token, and the router only reaches for frontier_a when a question actually calls for it, 36 of 46 times here, not on every single one.

Easy

Router 88.9% (8/9)
Sonnet 88.9% (8/9)
Tied

Medium

Router 94.7% (18/19)
Sonnet 100.0% (19/19)
Sonnet ahead by 1

Hard

Router 66.7% (12/18)
Sonnet 55.6% (10/18)
Router ahead by 2
The difficulty split is where it gets interesting rather than uniform: claude-sonnet-5 alone wins medium by one question, the router wins hard by two. By domain, the router leads knowledge 95.0% against 90.0%, and the two are exactly tied on math (65.0%) and reasoning (100.0%). These are single runs of 6 to 20 questions per bucket, not a claim that routing always wins every slice, the honest read is that the router matched or beat a frontier model used alone across every domain and most difficulty bands, at under a third of the cost.

Everything here is open source

The dataset, the pipeline, and the trained head all live in the open, not just described in this guide, and so does the benchmark result: 82.6% accuracy, roughly 3.1x cheaper than the same model called for every question instead of only the ones that need it. Every link below is the same set shown at the top, gathered again here now that you have the full story behind each one.

Source and pipeline

The full five-stage generation pipeline, tests, and reports, as a runnable repository.

Rewards dataset

46 tasks, 5 agents, 3 repetitions. Also on Hugging Face.

Task pool

1,110 tasks with prompt text. Also on Hugging Face.

Trained head

benchgen-router-lite. Also on Hugging Face.

Router Fidelity Benchmark

46 in-distribution tasks. 82.6% accuracy, roughly 3.1x cheaper than its own strongest pool member called alone.
Last modified on August 31, 2026