Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

Run a cross-family panel

Compose a panel from two or more model families, read the vote vector, and price the call multiplier before you turn it on.

How-to guideLast updated

On this page

One model grading another model’s answers gives you one opinion. If that grader has a blind spot, a phrasing it likes or a format it punishes, every score you collect carries the same one, and nothing in the score says so.

Three graders from three vendors have three different blind spots, and the rows where they disagree are the rows worth reading. That is what a panel is for: one judge with several seats, one verdict, and the ballots kept beside it.

It is also the one multi-opinion judging setup that composes from a config file in this build, and it multiplies your judging bill by its seat count. This page seats one, runs it past the validator, boots it, and reads a vote off a row.

You need a single-model judge working first. Configure a judge is that surface and how a judge call happens is what one grading call does. You need no extra subsystem, no new port and no second binary; you do need a credential for each seat’s provider, and a seat count you have priced. That price is what judging costs, and it is worth reading before you widen a panel rather than after.

1. Seat the panel

The seat rules are floors the config layer applies, so a panel that breaks one is stopped before anything boots. Seven of them decide whether your block loads at all.

The rule What it takes What a broken one does
seats at least three panel.members[] stopped, with need 3, have 2
families at least two distinct model families stopped, and the message calls diversity a floor rather than advice
judge.model required, on a panel too stopped, even though no seat reads the value
judge.api_key_env required, on a panel too stopped, for the same reason
judge.endpoint_env not readable on a panel stopped; name a variable on each seat instead
judge.self_consistency_k 1, or absent stopped: a panel mints no version above k=1
panel.rule majority, which is also the absent-reading any other value is stopped

The two required leaves read like leftovers from the single-model form. Take either one out and the config is stopped, one message each. A panel’s seats are its own transports, so the top-level judge.model is sent nowhere, and the boot log publishes it under the name configured_judge_model to say so. Write both anyway.

Here is the evaluator entry, as a fragment of a working config:

evals:
  evaluators:
    # …
    - id: judge-task-completion
      sample_rate: 0.20
      max_usd_per_day: 20.0          # one cap for the whole panel
      judge:
        external: allow              # the air-gap gate, on the panel as a whole
        method: panel
        model: gemini-2.5-flash      # required on a panel, and no seat reads it
        api_key_env: JUDGE_API_KEY   # required on a panel, for the same reason
        panel:
          rule: majority             # the whole vocabulary, and the absent-reading
          members:
            - { model: gemini-2.5-flash, api_key_env: JUDGE_GEMINI_KEY }
            - { model: mistral-large-2,  api_key_env: JUDGE_MISTRAL_KEY }
            - { model: llama-3-70b,      api_key_env: JUDGE_LLAMA_KEY }

Three seats, three families, three credentials, one cap. The family is the leading run of letters in the model id, so claude-haiku-4-5 and claude-opus-4-8 are one family and gemini-2.5-flash is another; a bigger model is not a second family. Each seat may also carry endpoint_env, naming a variable that holds the URL that seat posts to, and one seat’s key travels to one seat’s endpoint.

Every seat is checked a second time at grading time, against the model that produced the answer under judgement. That check fails closed: one seat sharing the generator’s family stops the whole invocation rather than that seat alone.

What that block does with one item

A panel fans one item out to N seats and reduces their ballots to one verdictOne item fans out to every seat of the panel. A panel needs at least three seats drawn from at least two model families, and each seat runs the same inner boolean method, so each seat answers with one ballot — pass or fail. In the worked example three seats from three families vote pass, pass and fail. The tally requires a strict majority: twice the leading count must exceed the seat count. Two of three clears it, so the panel decides pass and discloses a margin of nought point three three, the lead over the seat count. A three-nil vote would decide the same way with a margin of one, which is why the vote vector travels whole rather than collapsing into a single verdict — the two are different facts. A vote too even for a strict majority is not reported as a plurality: the panel abstains with the reason panel_split, and the margin is disclosed either way. That branch carries a precondition the drawn fan cannot meet. The rule is that twice the leading count must exceed the seat count, and the ballot vocabulary has exactly two tokens, so the leader always holds at least half the seats rounded up and an odd-seated panel always decides — two against one included. A split needs an even seat count and an exact tie, and the margin of every split this build can reach is therefore exactly nought. Three seats is the floor and there is no configured maximum, so four seats is the smallest shape that can abstain this way. The cost is N calls inside one grading call, charged against the single daily cap and re-checked before each seat, and a budget stop refuses the whole invocation with the calls already made carried in the returned spend. A seat that casts no ballot refuses the invocation too, because a verdict from the remaining seats would be a different instrument's answer under the same version string.judge.method: panel — one judge, N seats, one daily capone itemthe instruction andthe output to gradeseat 1 · gemini-2.5-flasha first family — ballot: passseat 2 · mistral-large-2a second family — ballot: passseat 3 · llama-3-70ba third family — ballot: failAt least 3 seats, from at least 2 model families — both are floors, not advice.Every seat runs the same inner boolean method, so every ballot is one ofexactly two tokens: pass or fail.tally — a strict majoritytwice the leading count must exceedthe seat count · rule: majority2–1 decides → verdict passvotes: pass, pass, failmargin 0.33 — a 3–0 would decide thesame way at margin 1.00, a different facttoo even → an abstentionabstain_reason: panel_splitneeds an EVEN seat count and an exacttie, so the 3 seats above always decidemargin is then exactly 0, and disclosedThe vote vector always travelsThe row carries every member's model and family, the ballots in seat order, the rule and the margin — decided or not.Cost is N calls under the one max_usd_per_day, re-checked before each seat. A budget stop refuses the whole invocation and iscounted, with the calls already made carried in the returned spend. A seat that casts no ballot refuses it too.

Read it left to right. One item fans out to every seat, each seat runs the same boolean method and answers with one ballot, and the tally on the right reduces the ballots to one verdict. The two boxes under the tally are the two answers a panel can give.

Two details in that drawing decide your seat count. Ballots come from a closed pair of tokens, pass and fail, and a decision needs a strict majority: twice the leading count has to exceed the seat count. Three seats therefore always produce one, because the closest vote available to them is two to one. Seat an even number and a tie clears no majority, so the panel abstains with the reason panel_split and the ballots still travel.

2. Validate before you boot

Run the validator over the file. It is the layer that applies every floor in the table above, and it prints two warnings on a panel that loads:

$ tokentriage validate --config tokentriage.yaml
warning: evals.evaluators[1].judge: an LLM judge PHONES OUT to its provider on every sampled request (the model named by evals.evaluators[1].judge.model, using the key named by evals.evaluators[1].judge.api_key_env) — the single-binary air-gap posture is broken for this evaluator, and its outcomes are Basis: reported until the B.7 validation protocol promotes them
warning: evals.evaluators[1].judge.panel: this judge is a PANEL of 3 members, so a sampled request costs 3 judge calls, not one — every member listed under evals.evaluators[1].judge.panel.members is its own transport and every provider-backed one is its own phone-out, all of them capped by the single evals.evaluators[1].max_usd_per_day. The panel is ONE instrument: it votes with all its members or it does not vote, so a member that cannot be reached refuses the whole invocation rather than being dropped from the vote. No member id is repeated back here — read the lines this message points at
warning: evals.evaluators[1].judge: an LLM judge PHONES OUT to its provider on every sampled request (the model named by evals.evaluators[1].judge.model, using the key named by evals.evaluators[1].judge.api_key_env) — the single-binary air-gap posture is broken for this evaluator, and its outcomes are Basis: reported until the B.7 validation protocol promotes them
warning: evals.evaluators[1].judge.panel: this judge is a PANEL of 3 members, so a sampled request costs 3 judge calls, not one — every member listed under evals.evaluators[1].judge.panel.members is its own transport and every provider-backed one is its own phone-out, all of them capped by the single evals.evaluators[1].max_usd_per_day. The panel is ONE instrument: it votes with all its members or it does not vote, so a member that cannot be reached refuses the whole invocation rather than being dropped from the vote. No member id is repeated back here — read the lines this message points at
mode:      ledger
listen:    127.0.0.1:8787   admin: 127.0.0.1:9090
upstreams: 2
  - anthropic              https://api.anthropic.com
  - openai                 https://api.openai.com
tiers:     2 (cheapest -> most capable)
  0. cheap      -> openai/gpt-5-mini
  1. frontier   -> anthropic/claude-opus-4-8
rules:     0
OK: configuration is valid

That capture is a scratch copy of the shipped examples/evals/journey-2-validated-judge.yaml example with the judge entry above pasted over its judge entry, run on 2026-08-24 against a source build on Windows. Exit status was 0, and two consecutive runs were byte-identical. Two things in it are that file’s rather than yours: the evaluator index [1], which is where the judge entry sits in this config, and the summary block underneath, which reads back that file’s listeners, upstreams and tiers. Warnings go to stdout and errors go to stderr, so a fence like this one shows both because a terminal does.

Each warning appears twice, and the doubling is the binary’s. Two validation passes each produce it and one print loop emits both, on every message this surface prints. A transcript of this command showing one copy of a warning has been edited.

The panel warning is the sentence to keep. It names the seat count, states the call multiplier in the same breath, and says which cap the extra calls land under: the one max_usd_per_day on the evaluator entry, not one per seat.

3. Boot it, and read the instrument

Start the proxy and read the wiring line. It is written once, at composition, before any traffic:

{"time":"2026-08-24T12:25:30.7715974-07:00","level":"INFO","msg":"evals: judge evaluator wired (air-gap gate OPEN — this deployment PHONES OUT)","evaluator":"judge-task-completion","judge_method":"panel","judge_instrument":"panel(majority:gemini-2.5-flash,mistral-large-2,llama-3-70b)","configured_judge_model":"gemini-2.5-flash","api_key_env":"JUDGE_API_KEY","sample_rate":0.2,"max_usd_per_day":20,"panel_members":3}

One line out of a longer boot log, captured the same day from the same source build, with the listeners moved to port 0 and replica.name pinned so the capture carries no hostname. The timestamp and its local offset are that machine’s. Everything else is read from the config and the shipped defaults, so the same file gives you the same values.

Four fields on it answer four different questions:

Field What it tells you
judge_method panel, read off the composed scorer rather than off the config text
judge_instrument the composite id every row this judge writes will be attributed to
configured_judge_model the top-level judge.model, published under a name that says no seat used it
panel_members how many calls one sampled request will make

A single-model judge prints judge_model where this line prints configured_judge_model, and its judge_instrument is the model id. Reading judge_instrument is how you tell from boot output alone that a panel composed rather than quietly grading with one model.

4. Read the vote vector off a row

A panel-graded outcome carries a panel block beside its judge disclosure. This is the panel object from the tracked golden fixture the product pins its own wire shape against:

    "panel": {
      "members": [
        { "model": "claude-haiku-4-5",  "family": "claude" },
        { "model": "gpt-4o-mini",       "family": "gpt" },
        { "model": "gemini-2.5-flash",  "family": "gemini" }
      ],
      "votes": ["pass", "fail", "pass"],
      "rule": "majority",
      "margin": 0.3333333333333333
    }

That is the fixture’s own bytes with whitespace added and no value changed; go test compares the whole file byte for byte on every run, and it passes on this tree. It is a pinned shape rather than a live capture, and its three seats are the fixture’s, not the config above.

Field What it says
members[].model the model that sat in that seat
members[].family the family resolved for it when the panel was composed
votes the ballots, positionally aligned with members
rule the aggregation rule that produced the verdict
margin the lead over the seat count

margin is derivable from votes, which is what makes it checkable: two pass against one fail over three seats is (2 − 1) / 3, the 0.3333… printed above. A 3–0 on the same panel prints 1. Both decide the same way and they are not the same evidence.

The judge disclosure beside it names the panel as one instrument. judge.method reads panel and judge.model reads the composite panel(majority:…) string, the same one the boot line printed as judge_instrument. A promotion is pinned to that composite and to the version the member tuple hashes into, so moving one seat moves both halves of the pin and the subject goes back to a reported basis until it is validated again.

5. What one panel-graded row costs

Three seats is three provider calls for one graded row, and the validator says so before you boot. There is one budget behind them: max_usd_per_day on the evaluator entry covers the whole fan-out rather than a seat each.

The cap is re-read before each seat, which is the part worth planning for. A panel that is stopped at its third seat has made two calls, and those dollars leave your account. They do not all reach the counter the cap is checked against, which is a measured gap and the reason to price a panel before you widen one.

What judging costs, and how to cap it does the arithmetic: the multiplier per method, what a call costs on a measured ledger, and where the resulting dollars show up.

What can go wrong

A seat cannot be reached. A transport failure, an unparseable reply or a non-boolean verdict on any seat stops the whole invocation, which is counted. No row is written, and the seats that already answered are still billed. The panel is validated as one instrument and its version hashes the member list, so a verdict from the surviving seats would be a different instrument answering under this one’s version string.

Diagnosing which seat broke is harder than it should be. A dead seat, a panicking seat, a mid-fan-out budget stop and a malformed vote vector are tallied apart inside the panel and reach an operator as one skip named evaluator_error, so a seat that fails on every row reads as a broken evaluator rather than as a bill.

A seat shares the generator’s family. The same-family check runs per seat against the model that produced the graded answer, and it fails closed. An unreadable family on either side is stopped in the same way, because “we could not tell” is not the same answer as “they differ”. Boot warns when no configured tier is cross-family with the judge, so you find this before your rows do rather than after.

The cap runs out mid-fan-out. Identical shape to a dead seat: the invocation is stopped, the completed calls are billed, and the stop is counted under the budget’s own reason rather than the transport’s.

An even seat count ties. Four seats at 2–2 clear no strict majority, so the outcome is an abstention carrying panel_split, with the vote vector and the margin disclosed. That is an answer about the item rather than a failure, and it is counted apart from the refusals above. When a judge can’t tell has the four abstention reasons.

self_consistency_k above 1. A panel is stopped for it at tokentriage validate and again at boot:

$ tokentriage validate --config tokentriage.yaml
Error: configuration is invalid: config tokentriage.yaml: evals.evaluators[1].judge.self_consistency_k: 3, but evals.evaluators[1].judge.method resolves to panel — judgecore mints NO k>1 MethodVersion for a panel (SelfConsistencyVersion is keyed by method id, while a panel's tuple hashes its member list), so a k-sampled panel would execute and could never be version-disclosed, and a version that cannot move is a promotion B.7 can never reset. Configure the panel at k=1 (§2.1)

One copy of the doubled message, from the same probe set; the config path is this run’s and the evaluator index is this file’s. Sampling the same seat repeatedly is a different leaf on a different plane, and it is shadow.duplicate.self_consistency_k.

What this page does not cover

Whether a panel is the right control for your problem. Position bias, self-preference and the swap trial are position bias, self-preference and panels, which is where the panel sits among the other controls rather than beside its config.

The other four methods. pointwise-bool is the absent-reading default and the only other setup that composes over a judge: transport; judge methods is the catalogue, with what each decides and whether a config file can reach it.

Pairwise judging. It reaches production on shadow.duplicate.pairwise, a different leaf on a different plane, and that plane has no endpoint leaf at all: it dials a compiled-in provider URL, so it needs outbound traffic and cannot be rehearsed against a listener on your own box.

Getting the panel promoted. A panel reaches a measured basis through the same protocol a single judge does, scoped to the composite instrument, and the walk that promotes a judge sets that protocol out gate by gate.