Repository navigation
llm_judge: support non-Anthropic judge routes (LiteLLM / OpenAI-compatible) #136
Description
Summary
llm_judge can only reach Anthropic models. Both judge transports are Anthropic-Messages shaped, LiteLLMRoute is explicitly refused, and resolve_evaluation_route pins evaluation back to a Claude backend even when the agent itself runs through a gateway. There is no configuration under which a non-Claude model can grade.
Current behaviour (main @ 0.11.1)
criteria/llm_judge.py—_invoke_tool_channelmatchesBedrockRoute/DirectRoute/LiteLLMRoute; the LiteLLM arm returns"llm_judge: evaluation route must be Bedrock/Direct, got LiteLLM".models/routing.py—resolve_evaluation_route: "agent_route is LiteLLMRoute → pin evaluation to a constant Claude backend."evaluation/judge_bedrock.py— posts"anthropic_version": "bedrock-2023-05-31"with Anthropic-shapedtools/tool_choice, and the reply is parsed byextract_verdict_from_anthropic_response. So even a non-Claude model hosted on Bedrock cannot be used:to_bedrock_modelonly vendor-qualifiesclaude-*ids, and the request body wouldn't be understood regardless.agent_judgeis documented as "built-in kinds only — agent_judge runs a Claude Code sub-agent."
Why this matters
- Judge-model choice cannot be evaluated in-product. Any comparison has to be run outside the framework, so the results can't be reproduced by
coder-evalitself or wired into CI. - Self-preference. The agents under test are frequently Claude, and the grader is always Claude. There is currently no way to run a cross-vendor grader to check whether a model family is scoring its own output favourably — a structural gap in evaluation methodology, independent of cost.
- Cost. Judging is a small share of total run cost, but the per-token ratio between vendors is large enough to matter as judged volume grows or rubrics get richer.
We ran an offline comparison by replaying recorded judge prompts against a non-Claude model, calling the provider directly outside the framework. It reproduced the incumbent grader's pass/fail verdicts on 84 of 85 criteria. Whether that particular model is the right choice is beside the point — the framework currently makes the question unanswerable without bypassing it.
Proposal
Add an OpenAI-compatible judge transport and allow it to be selected:
evaluation/judge_openai.py, alongsidejudge_bedrock.py/judge_anthropic.py— POST/chat/completionswith a forced function tool, same retry/backoff contract as the Bedrock invoker.- An OpenAI translation of
SUBMIT_VERDICT_ANTHROPIC_TOOL(input_schema→function.parameters). - A verdict extractor for
choices[].message.tool_calls[], mirroringextract_verdict_from_anthropic_response. - A real
case LiteLLMRoute()arm in_invoke_tool_channel, and relaxingresolve_evaluation_routeso a gateway route can serve the judge instead of being pinned to Claude. - Usage mapping (
prompt_tokens/completion_tokens→TokenUsage).
Two findings from prototyping all of the above, worth encoding in whatever lands:
- The OpenAI surface rejects
reasoning_effortwhen function tools are present on/chat/completions("Function tools with reasoning_effort are not supported"); only/responsesaccepts both."none"is special-cased and does work on/chat/completions. - Some reasoning models reject
temperaturevalues other than their default ("Unsupported value: 'temperature' does not support 0"), so the judge'stemperature=0cannot be assumed portable across providers.
Related, smaller
(a) claude-sonnet-5 cannot be used as a judge at all. judge_bedrock.py puts temperature in the request body unconditionally, and sonnet-5 responds 400 "temperature is deprecated for this model". Setting model: anthropic.claude-sonnet-5 on an llm_judge criterion therefore fails at dispatch on every criterion. Making the field conditional looks like a one-line fix.
(b) There is no way to set the judge model globally. -D override roots are ('agent', 'run_limits', 'sandbox'), and neither ExperimentDefaults nor ExperimentVariant carries a success_criteria field. Changing the judge model across a suite currently means editing every task YAML that declares an llm_judge, or monkey-patching the default on LLMJudgeCriterion.model (which additionally requires TaskDefinition.model_rebuild(force=True), since the criterion union is compiled at import — patching only LLMJudgeCriterion silently has no effect through the task loader). A success_criteria override root, or a judge section in ExperimentDefaults, would turn judge-model A/B into a config change.
Happy to contribute the transport — we have working implementations of (1)–(3) and (5) from the prototype.
Activity
akshaylive commentedon Aug 26, 2026
Design sketch for (b) — judge model/route as config, without criteria knowing about
TaskDefinitionChatted through a design for the "no way to set the judge model globally" gap (and, while there, the judge-route side of the self-preference concern). Posting it here in case it's useful before anyone starts on the transport work.
Principle: criteria (
models/criteria.py,criteria/*.py) stay task-blind — they only ever see their own Pydantic fields plusCheckContext, the orchestrator-resolved bundle already used forroute/reference_dir(criteria/base.py). The fix generalizes that existing seam instead of teachingllm_judgeto readTaskDefinition.1.
CheckContextgains one generic, namespaced field:Any checker — built-in or third-party plugin — can read
context.for_criterion(self.type_name)without core changes. This also means the judge-model fix isn't a bespoke field; it's the first consumer of a mechanism that composes for future cross-cutting needs.2. New task-YAML root,
checker_context, namespaced by criterion type, layered through the existing 5-layer merge (task → experiment defaults → variant → CLI-D):modelpicks the judge model.routeis anApiBackendstring (direct/bedrock/litellm) — a selector, decoupled from the agent's own route, so cross-vendor grading (item 2 in the issue) is expressible without secrets ever touching task YAML.3. Orchestrator (already holds
self.task, at its existing route-resolution call site) resolves the override before buildingCheckContext:4.
resolve_evaluation_routegrows one override branch: ifbackend_overrideis set, build that specific route from env — same constructionresolve_routealready does for the agent (LITELLM_BASE_URL/LITELLM_AUTH_TOKEN,AWS_BEARER_TOKEN_BEDROCK/AWS_REGION,ANTHROPIC_API_KEY) — and raiseValueErrornaming the missing env var if that backend isn't configured, rather than silently falling through to the pin-to-Claude default. Credentials never leave env vars; only the backend choice is task/experiment-authored.5.
llm_judgechecker changes by one line:routeis fully consumed upstream — the checker never sees the string"litellm", only the resolved, credentialedApiRoute.This is purely the config-plumbing half. It's gated on the actual transport (items 1–3, 5 in the proposal above:
judge_openai.py, the tool-schema translation, the verdict extractor, usage mapping) —route: litellmhas nothing to select until_invoke_tool_channelhas a realLiteLLMRoutearm. Happy to split this into its own PR either before or after the transport, whichever the maintainers prefer.