Seating Jev (orq classify) as a judge in evaluatorq
POST /v3/router/classify with typesafe/jev-latest: typed
questions (yes/no, choice, score) over a state, returning probabilities and a priced usage block in ~0.7 s at
$0.042 per million input tokens. Today that model id 400s as a judge, because run_judge only knows
Chat Completions and Responses. The change adds a third endpoint inside run_judge, chosen from the
model catalogue, and lets llm_jury build the classify question from the panel-level fields it already
has. A judge stays a string id, so Jev seats next to LLM judges on the same panel with no schema change to
judges, presets, replacements or cyclic rotation.
P1 must be right before code is written P2 defaults chosen, cheap to change later
1 · Endpoint routing inside run_judge P1
The one shared seam every judge caller goes through (common/judge.py:510). It already picks
Responses vs Chat from the catalogue. Jev becomes a third branch keyed on the catalogue's
metadata.supports_classify, and only when the client routes through the Orq router. When the catalogue is
empty or its fetch failed, a hardcoded set holding typesafe/jev-latest answers instead and warns once, so an
outage never sends Jev to chat.
Why this seam and not a separate run_classify_judge or llm_classify() judge.py:353, :510
Endpoint choice already lives here (_resolve_responses_model at judge.py:353 gates on the
catalogue and the router). All five run_judge call sites (jury, pairwise, two redteam paths) get Jev
by naming the model. A separate function would have needed every parameter the parent already takes, and a
separate evaluator factory would have made a mixed panel impossible.
Retry stays with_retry around the whole attempt, same as today: 429 and 529 back off, everything
else classifies to JudgeError. timeout_ms applies. SDK retries stay off at this boundary.
2 · Verdict modes map one-to-one onto Jev question types P1
Nothing about the jury contract changes. Each existing llm_jury mode is one Jev primitive; the
derivation of value is code, never a model opinion.
| llm_jury mode | Jev question | Jev answer | value (new) | passed (unchanged) |
|---|---|---|---|---|
Booleancategorical, no labels |
noulinstructions = criteria |
{"noul": 0.99}probability, no confidence |
noul >= thresholdthreshold default 0.5, now meaningful in boolean mode for Jev |
the boolean |
Labeledcategorical + labels |
choicecriteria = {label: description | null} |
{"choice": "neutral", "probabilities": {…}, "confidence": 0.96} |
the choice string |
value in passing_labels |
Numericnumeric |
scorecriteria = levels (2 to 10 ordered descriptions) |
{"score": 1.97, "legend": {…}, "probabilities": {…}, "confidence": 0.96}score is a probability-weighted mean over level index 0…n-1 |
score / (n - 1) into (0, 1)score_range must stay at its default |
value >= threshold |
Pairwisellm_jury_pairwise |
choice over A / B / tie |
as labeled | the choice, un-swapped as today | n/a (winner) |
explanation from the returned numbers. The complete validated answer travels through EvaluationResult.raw_output["jury"] → votes → repetitions → raw_output; reported confidence and probabilities also become judge.confidence / judge.probabilities span attributes. Low confidence is not mapped to an abstention. send_results_to_orq() strips raw_output, so the hosted Orq experiment view does not receive these details.3 · Prompt vs state: what each judge kind sees P1
An LLM judge gets a rendered prompt. Jev gets instructions (the criteria) plus a
state object. The state is built from the template's {{…}} placeholders, resolved
through the same template engine and keyed by dotted path, with criteria excluded. An explicit
state_fields list replaces the placeholder scan.
tone = llm_jury(
name="tone",
criteria="Is the reply polite and on-topic?",
judges=["openai/gpt-5.4-mini", "anthropic/claude-sonnet-4-6", "typesafe/jev-latest"],
labels={"rude": "hostile or dismissive", "neutral": None, "friendly": "warm and helpful"},
passing_labels=["neutral", "friendly"],
)
# LLM judges: DEFAULT_TEMPLATE rendered, labels + descriptions in schema and system prompt.
# Jev: instructions=criteria, criteria=labels, state = the template's placeholders minus criteria.
tone = llm_jury(
name="tone",
prompt="# Reply\n{{output.response}}\n\n# Conversation\n{{input.all_messages}}\n\nRate the tone.",
criteria="Is the reply polite and on-topic?", # both allowed only when a Jev judge is seated
judges=["openai/gpt-5.4-mini", "typesafe/jev-latest"],
labels={"rude": "hostile or dismissive", "neutral": None, "friendly": "warm and helpful"},
state_fields=["output.response"], # optional: override the placeholder scan
)
# LLM judges render `prompt` exactly as before. Jev never sees the prompt.
helpfulness = llm_jury(
name="helpfulness",
criteria="How helpful is the answer?",
judges=["openai/gpt-5.4-mini", "typesafe/jev-latest"],
verdict_kind="numeric",
levels=["does not address the question", "partially addresses it", "fully answers it"],
threshold=0.7, # score_range stays (0, 1) — non-default raises at construction
)
# Jev: score in [0, 2] → value = score / 2. LLM judges: levels go into the value field description.
POST https://my.orq.ai/v3/router/classify (0.7 s, 2026-09-19)
{"model": "typesafe/jev-latest",
"state": {"input.all_messages": "...", "output.response": "Paris."},
"questions": {"verdict": {"type": "choice", "instructions": "Grade the answer",
"criteria": {"correct": "fully correct", "partial": "partly", "wrong": "incorrect"}}}}
{"model": "jev-latest",
"answers": {"verdict": {"type": "choice", "choice": "correct",
"probabilities": {"correct": 1, "partial": 0, "wrong": 0}, "confidence": 1}},
"usage": {"input_tokens": 379, "output_tokens": 68,
"input_cost": 0.00001592, "output_cost": 0, "total_cost": 0.00001592}}
What changes for an LLM judge on the same panel
The prompted verdict shape stays {explanation, value, abstain}, with one JuryVote per judge. The local EvaluationResult.raw_output["jury"] record now adds optional raw_output on each repetition: the complete validated answer for a classify judge, None for a prompted judge. Three inputs change, all additive.
| Surface | Today | New |
|---|---|---|
| Verdict schema, labeled mode | value: Literal["rude", "neutral", "friendly"] |
same Literal, plus Field(description="rude: hostile or dismissive; neutral; friendly: warm and helpful"). The enum is still enforced by the provider; the description is what the model reads. |
| Verdict schema, numeric mode | value: float, prompt says "between 0 and 1" |
value: float with Field(description="0.0 = does not address the question; 0.5 = partially addresses it; 1.0 = fully answers it") when levels is set. Without levels, unchanged. |
| Default system prompt | "value must be exactly one of: rude, neutral, friendly" | "value must be exactly one of: rude (hostile or dismissive), neutral, friendly (warm and helpful)". A caller-supplied system_prompt is untouched. |
| User prompt | render_template(prompt or DEFAULT_TEMPLATE, replacements) |
identical. When prompt and criteria are both set, the LLM judge renders prompt; criteria only reaches it if the template references {{criteria}}. |
| Output, cost, tracing | Responses or Chat, priced by the router | identical. No new span attributes on LLM judge spans; judge.confidence / judge.probabilities appear only on Jev spans. |
A panel without a Jev judge and without levels or dict labels produces byte-identical requests to today.
4 · What llm_jury gains, field by field P2
| Field | Today | New | Who reads it |
|---|---|---|---|
judges | list[str] | unchanged; a Jev id is just another string | both |
criteria | substituted into {{criteria}} | also Jev instructions | both |
prompt | exclusive with criteria | may coexist with criteria when a Jev judge is seated | LLM only |
labels | list[str] | also dict[str, str | None] (a list still works, no descriptions; not breaking); descriptions land in the Literal field's description and the system prompt, and in Jev criteria | both |
levels | — | new, list[str] of 2 to 10 ordered descriptions; required for a Jev judge in numeric mode; feeds the LLM value description too | both |
state_fields | — | new, list[str] of dotted paths; replaces the placeholder scan | Jev only |
score_range | any increasing pair | must be default (0, 1) when a Jev judge is seated | both |
threshold | numeric mode | also boolean mode on Jev (noul >= threshold) | both |
temperature, max_tokens, reasoning_effort, structured_output, extra_kwargs, extra_body, system_prompt, api | sent | ignored by Jev, one warning via warn_unread_config_fields; still sent to LLM judges on the panel | LLM only |
repetitions | N calls per judge | works; warns once on a Jev judge (near-deterministic, pays for the same answer) | both |
Construction-time checks use the same hardcoded classify set (the catalogue is async, so a
constructor cannot consult it): prompt without criteria, numeric without levels,
non-default score_range. Run time uses the catalogue first, the set as fallback.
5 · Decisions to validate, most consequential first
These were settled in the grill. Each names the alternative that was rejected, so a disagreement is one line to state.
run_judge, keyed on the catalogue P1Every caller gets Jev by naming the model. Redteam and simulation inherit the seam without changes.
run_classify_judge (would need every parent parameter anyway); an llm_classify() factory (no mixed panels).Panel-level fields serve both judge kinds. Per-judge settings remain a separate design problem, and a later per-judge spec covers Jev like any other judge.
judges=[…, ClassifyJudge(model, instructions, state_fields)].Only what the template references reaches Jev, keeping the 32k state limit in view and matching how the model is meant to be used. state_fields is the explicit override.
{input, output, expected_output} object.Jev's score spans level index 0…n-1; dividing by n-1 puts it on the same scale as an LLM judge asked for a 0 to 1 float, so mean/median aggregation across a mixed panel is meaningful.
score_range and rescaling into it.value: Literal[labels] already enforces the set (llm_jury.py:45); this adds Field(description=…) with the descriptions, mirroring the orquesta-web evaluator runner's create_model approach.
The implemented explanation is a one-liner such as "choice='neutral' (confidence 0.96)". Read complete validated answers through EvaluationResult.raw_output["jury"] → votes → repetitions → raw_output; reported confidence and probabilities also remain on judge spans. Fields the provider did not report are omitted.
send_results_to_orq() still strips raw_output from the hosted experiment upload.Reported confidence is retained per repetition in local results and on judge spans. It does not change the verdict or reach the hosted experiment upload.
Today only typesafe/jev-latest is a classify model. supports_classify(model) reads the catalogue entry on the qualified id and, when the catalogue is empty or failed, answers from that set with one warning. The same set drives the construction-time checks, so there is one detector, not a prefix test plus a catalogue.
typesafe/ prefix test (provider-prefix detection had already routed models incorrectly); raising when the catalogue is unavailable (the motivating 400 stays reachable during an outage).A preset seating Jev next to GPT judges sets reasoning_effort for the panel; rejecting would break that seating.
6 · Out of scope, and gotchas already known
- Red teaming and agent simulation are not adapted in this ticket. They call
run_judgewith freeform templates and no verdict spec, so they need their ownClassifyQuestionconstruction later. The seam is ready. - Presets do not seat Jev.
jury_presets.pyis untouched; a user liststypesafe/jev-latestinjudgesby hand. - Only on the Orq router. Classify is gated on
client_routes_through_orq; an injected OpenAI client never sends it. - Catalogue shape.
ModelInfogainssupports_classifyfrommetadata.supports_classify; the entry has nosupports_responses_api, so today's routing legitimately falls to chat and 400s. - Response model id comes back bare (
"jev-latest", notypesafe/prefix), same patternprice_usagealready tolerates. Cost is router-priced, soprice_usageis a no-op. - Limits. 64k context; state plus the longest question must fit in 32k tokens. A too-large state is a provider 400, surfaced as
JudgeError.API_STATUS. - Empty choice criteria is a 400 (
"criteria must contain at least one option"); construction already requires two or more labels.
7 · Files touched
| File | Change |
|---|---|
common/judge.py | ClassifyQuestion model, classify= keyword on run_judge, _classify_judge leg, endpoint literal gains 'classify', EvaluatorResponsePayload design note names both producers |
common/llm_call.py | execute_classify: AsyncOpenAI.post('/v3/router/classify', ...) so 429/529 raise SDK error types and with_retry plus _classify work unchanged; usage + cost onto the span, trace headers |
common/model_catalogue.py | ModelInfo.supports_classify: bool = False appended last (positional constructors keep working), parsed from metadata.supports_classify; supports_classify(model) lookup on the qualified id with the hardcoded fallback set |
common/template_engine.py | extract_template_paths(template) and a per-path resolver, used by render_template and the state builder |
common/tracing.py | span attributes judge.confidence, judge.probabilities; operation classify |
llm_jury.py | labels dict form, levels, state_fields, prompt+criteria rule, question building, value derivation, construction checks, warnings |
tests/ | fake classify transport; per-mode value derivation; error branches (no question, 400, timeout); guardrail allowlist for the new call site |
docs/llm-as-a-jury.md, docs/pairwise-judging.md, CHANGELOG.md | "Jev as a judge" section, pairwise note, changelog entry |