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.
Source and pipeline
Rewards dataset
Task pool
Trained head
benchgen-router-lite. Also on Hugging Face.Router Fidelity Benchmark
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.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 intodata/ 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.
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:
--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.
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:
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.
build_pool does, step by step:
Load and map
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
task_id first, so which copy survives is deterministic. This build dropped 0 duplicates.Balance
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
(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.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:
Domain
Math: 370 (33.3%)
Reasoning: 370 (33.3%)
Difficulty
Hard: 427 (38.5%)
Easy: 179 (16.1%)
Split
Test: 245 (22.1%)
Validation: 217 (19.5%)
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:
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:
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:
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 onlydata/ and configs/, needs no
network and no API key, and exits non-zero when the gate says STOP so CI can enforce it:
configs/gates.v1.json, each with a continue level and a stop level:
- 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.
test split, because a routing dataset
that reports only the winner’s score invites exactly the mistake above:
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. Therlpr 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:
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.(task, agent) reward is already in
the dataset, so a full training run makes zero API calls and costs nothing beyond the embedding
pass.
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 usedQwen/Qwen3-1.7B) and Dataset at the rewards dataset,
benchgen/router-pilot.

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 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 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 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
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:
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:
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.

The completed job, registered and ready as benchgen-router-lite, selectable anywhere BenchGen lets you pick a model
Run it yourself
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
Trained head
benchgen-router-lite head this platform run and this script both produce.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 frombenchgen/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.

The Router Fidelity Benchmark overview: 46 in-distribution tasks, env-var-first model connection, and a routing report for router models
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 exposesRPE_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 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 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.

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
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.

One routed question as claude-sonnet-5 received it: the benchmark's own math prompt and boxed-answer instruction, passed through untouched
benchgen-router-lite, along with the
routing and generation timings, throughput, and the exact input and output token counts for that
single question.

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
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.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:

The completed Router Fidelity Benchmark run: 82.6% overall, difficulty and domain breakdowns, and the routing distribution across three pool members
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?
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
frontier_a when a
question actually calls for it, 36 of 46 times here, not on every single one.
Easy
Sonnet 88.9% (8/9)
Tied
Medium
Sonnet 100.0% (19/19)
Sonnet ahead by 1
Hard
Sonnet 55.6% (10/18)
Router ahead by 2
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
Rewards dataset
Task pool
Trained head
benchgen-router-lite. Also on Hugging Face.