feat(eval-routing): decouple judge/agent_judge backend+model from the agent's route - #137
Conversation
… agent's own route
Adds `checker_context.api_route: {route, model}` on TaskDefinition (4-layer
merged like `agent`/`simulation`), letting a task/variant pick which backend
and model the evaluation side (llm_judge/agent_judge/simulator) uses,
independent of the agent under test — enabling cross-vendor grading and
cheaper judge models without editing every task YAML. The override is baked
into the resolved ApiRoute's own `model` field before any criterion runs, so
criteria stay task-blind (only ever reading CheckContext.route.model, never
checker_context/TaskDefinition directly). checker_context validates its shape
(unknown namespace/key/backend name raises) both at task-load time and after
the experiment-layer merge.
Separately, BedrockRoute/LiteLLMRoute no longer carry bearer_token/auth_token
fields — those secrets now flow only through the coder_eval.config.settings
singleton, read directly by each consumer (ClaudeCodeAgent._build_sdk_env,
judge_bedrock.invoke_bedrock_judge_async) at the point of use, so a route
object flowing through CheckContext/environment_info/logging never carries a
credential.
Includes fixes from a code review pass: Bedrock model_override is now
region-qualified when reusing the agent's own route (previously shipped a
bare alias to the Bedrock API); a missing bearer token now raises
JudgeInfrastructureError instead of an assert that handle_criterion_errors
was silently downgrading to a scored 0.0; eval_model is now recorded in
environment_info; the two orchestrator route-resolution call sites are
deduplicated into one helper.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Doesn't get us to luna — this is the selector, not the capability, and that gap is the whole reason #136 exists.
The judge can only be pointed at backends that reach Claude. The endpoint codex actually uses for luna isn't one of them, and the judge has no transport that could talk to it if it were — so nothing here moves the sonnet-4.6 → luna swap. Getting there needs a new backend on top of the transport work in #136. Fine as a prerequisite; worth saying so up front rather than merging this looking like it delivers the thing.
The simulator swap isn't covered either — its model comes from the simulation config, not the eval route.
Fix what you agree with on the rest:
- The default judge model silently changes. With no
checker_contextanywhere, the judge stops using the criterion default and picks upBEDROCK_MODEL— which the nightly sets to sonnet-5, which 400s ontemperature(verified against live Bedrock). That's all 361 llm_judge tasks in the skills suite once the pin moves. Fix: only populate the eval route's model when an override was actually supplied. - The unset-detection doesn't survive the docker driver — the task round-trip marks every field as set, so the model override is a silent no-op there while working on tempdir. Same fix retires it.
- No tests on any of the new paths. Every existing judge test uses a model-less route, which is why the suite stayed green through the above.
- The
modelhalf may be the wrong shape. Theroutehalf earns its keep; the model override is doing a job ajudge:block onExperimentDefaultswould do more directly — set the criterion's model explicitly and the two bugs above stop existing. Worth considering landing the selector alone and bringing the model override back alongside the transport.
Addresses a CodeQL finding on PR #137: mixing explicit returns per case with an implicit fall-through return (None) reads as a possible bug. Add a raising wildcard arm so every path returns explicitly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dge transport Addresses the PR #137 review blockers and adds the litellm-library-backed LiteLLMRoute judge transport: - resolve_evaluation_route no longer lets the agent's own env-sourced model (e.g. BEDROCK_MODEL) leak into the eval route's `model` on the no-override reuse/pin paths — route.model now means "an explicit checker_context override was given", restoring DEFAULT_JUDGE_MODEL as the judge's floor. - LLMJudgeCriterion.model is now `str | None = None` (was a materialized DEFAULT_JUDGE_MODEL default gated by model_fields_set, which doesn't survive the docker driver's model_dump/reload round trip). Precedence is now `criterion.model or route.model or DEFAULT_JUDGE_MODEL`, computed at check time. - _build_sdk_env's DirectRoute arm now neutralizes inherited Bedrock creds (AWS_BEARER_TOKEN_BEDROCK/CLAUDE_CODE_USE_BEDROCK), matching the LiteLLM arm, so an explicit `route: direct` can't silently spend the operator's Bedrock token. - Implement the `checker_context.api_route.route: litellm` judge transport via the `litellm` library (new `coder-eval[litellm]` extra), with a LiteLLMRoute.include_temperature flag to avoid a live round-trip on gateways that reject `temperature`. - Added ~15 tests covering backend_override/model_override resolution, the judge-model floor regression, docker-serialization round-tripping, and DirectRoute env neutralization — routing.py coverage 69% -> 96.5%. - Doc fixes: corrected stale claims about the judge-model fallback chain, the simulator sharing the agent's ApiRoute, and checker_context's placement/example in the guide and AB_EXPERIMENTS.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
litellm.acompletion() takes dozens of provider-specific kwargs (aws_access_key_id, vertex_project, api_version, ...) that LiteLLMRoute had no way to express, and secrets couldn't be put in task YAML anyway. Extend checker_context.api_route with two new keys, litellm-route only: - `params`: arbitrary passthrough dict merged straight into the litellm.acompletion() call — no allowlist to maintain, litellm validates param names itself. - `auth`: maps a kwarg name to the ENV VAR NAME (never the secret value) to resolve it from right before the call — so an arbitrary provider's auth shape (IAM keys, an Azure AD token, ...) is representable without a dedicated field per provider and without a secret ever landing in YAML. The plain LITELLM_AUTH_TOKEN requirement is relaxed to "LITELLM_AUTH_TOKEN OR a non-empty `auth` override", since some providers (e.g. Bedrock via IAM) have no `api_key` concept at all. `validate_checker_context_shape` rejects `params`/`auth` on any route other than `litellm` at task-load time, and type-checks `auth`'s values are env-var-name strings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…arams
Per review: the litellm judge transport was silently reading from
coder_eval.config.settings (litellm_base_url/litellm_auth_token) even
though checker_context.api_route.route: litellm is meant to be fully
task-author-owned and independent of the agent's own LiteLLM backend.
- LiteLLMRoute no longer carries base_url at all -- the agent's own
LiteLLM backend (_build_sdk_env, environment_info recording) now reads
settings.litellm_base_url directly instead of storing it on the route,
mirroring how the bearer token is already handled.
- The checker's litellm route is built ENTIRELY from
checker_context.api_route.{params,env_params} -- no implicit fallback
to the agent's LITELLM_BASE_URL/LITELLM_AUTH_TOKEN. `model` is now
required when route: litellm (no default open-weight/gateway model).
- Renamed `auth` -> `env_params` for clarity (it's not auth-specific --
api_base, aws_region_name, etc. can all be env-sourced too).
- Removed LiteLLMRoute.include_temperature and the BadRequestError
retry-without-temperature logic: invoke_litellm_judge_async no longer
takes a `temperature` kwarg at all -- a gateway-routed model may reject
it outright (observed live against an Azure AI deployment) with no
reliable way to detect that in advance, so the task author opts in via
`params: {temperature: ...}` if their model accepts it.
Verified end-to-end against a real Azure AI gateway (checker_context:
{api_route: {route: litellm, model: azure/gpt-5.6-luna, env_params:
{api_base: LITELLM_BASE_URL, api_key: LITELLM_AUTH_TOKEN}}}) -- SUCCESS,
score 1.000.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
c07a041 to
f7583c0
Compare
Two CI gates (Quality Gate, Windows Smoke Test) were failing pyright: without `--extra litellm` in `uv sync`, `import litellm` in judge_litellm.py resolved to the repo's own top-level `litellm/` directory (the LiteLLM PROXY scripts, a namespace package with no `acompletion`/`exceptions`/`types.utils`) instead of the real PyPI package, since the real litellm distribution was never installed. Also fixes 4 CodeQL findings from the latest analysis: - routing.py: resolve_route()'s match (unlike its sibling _resolve_backend_route) had no `case _:`, so a 4th ApiBackend member would silently fall through and return None (mixed explicit/implicit returns) — added the same exhaustive-match guard. - test_llm_judge_criterion.py: two redundant local `import json` (already imported at module top). - test_orchestrator.py: `coder_eval.orchestrator` was imported both via `import ... as orch_mod` and `from ... import Orchestrator, ...` — patch `coder_eval.config.settings` directly instead (same singleton object orchestrator.py already imports). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bai-uipath
left a comment
There was a problem hiding this comment.
LGTM overall. The transport landed, so this now does what the title says: a judge can be pointed at luna. The two grading-correctness bugs from the last pass are fixed and tested, and the judge-model floor holds. Fix what you agree with below.
route: litellmbreaks the simulator and agent_judge. Both run as Claude Code sub-agents on the resolved eval route, but only the judge readsparams/env_params. The SDK env builder still falls back to the agent's own LiteLLM settings, so a task with a simulation block or an agent_judge criterion either gets an empty base URL, or silently runs on the agent's proxy: the exact misroutingLiteLLMRoute's own docstring says must never happen. Either reject that combination at load, or thread the params into the sub-agent env. This is the blocking one for me, since the simulator swap is half the reason we wanted this.checker_contextis still an untyped bag. Two authored values, plus a hand-written allowlist validator called from two sites, plus a hand-rolled merge that records no config lineage.model's value type is never checked, somodel: 5loads clean and gets stringified into a model id. That was raised as blocking on the last review and the two new keys grew the validator instead of replacing it. Either land the typed model or say you are declining it, but it should not go quiet.- Nothing in the repo exercises the feature. No task, no experiment variant. The luna config that proves this works lives in a Slack message. One committed variant would make it real and give CI something to catch regressions with.
- Pin
api_versionfor the Azure path. The codex agent sets it explicitly for that deployment; the judge does not inherit it and litellm falls back to its own default, so judge and agent can hit different API versions of the same endpoint. Also: the docs sayazure_ai/, the tested config saysazure/. Different providers, different request shapes. Make the doc match what actually ran.
Minor: the LiteLLM secret assertion in the orchestrator tests is still vacuous, the token string it checks for is never set on the settings object it now patches.
…ve test Addresses bai-uipath's PR #137 follow-up review: - Blocking: checker_context.api_route.route: litellm dispatches llm_judge through the litellm library in-process, but agent_judge and the simulator run as real Claude Code CLI subprocesses speaking Anthropic Messages only. Orchestrator._reject_litellm_eval_route_if_unsupported() now raises a clear error at route-resolution time when route: litellm is combined with an enabled agent_judge criterion or simulation.enabled, instead of silently misrouting onto the agent's own unrelated LiteLLM settings. - checker_context is now typed (CheckerContext/ApiRouteContext pydantic models, extra="forbid") instead of a hand-validated open dict — deletes validate_checker_context_shape and its two call sites. A YAML `model: 5` is now rejected at load time instead of being str()-ified into a model id. _resolve_checker_context merges through the shared merge_layers engine (mirroring _resolve_simulation) and records config lineage. - Added tests/test_litellm_judge_live.py: a live regression test hitting a real gateway via litellm.acompletion, reusing the existing CODEX_API_KEY/ CODEX_BASE_URL/CODEX_MODEL CI secrets, wired into the codex-live-tests CI job — closes "nothing in the repo exercises the feature". - Docs: azure_ai/ -> azure/ (matches what actually ran), api_version pinning guidance, documents the new agent_judge/simulator restriction. - Fixed a vacuous test assertion: the orchestrator secret-leak test now actually sets litellm_auth_token before asserting it's absent from environment_info. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
checker_context.api_route: {route, model}onTaskDefinition(4-layer merged likeagent/simulation), letting a task/variant pick which backend and model the evaluation side (llm_judge/agent_judge/simulator) uses, independent of the agent under test — enabling cross-vendor grading (checking for self-preference bias) and cheaper judge models without editing every task YAML.ApiRoute's ownmodelfield before any criterion runs, so criteria stay task-blind —llm_judgeonly ever readsCheckContext.route.model, neverchecker_context/TaskDefinitiondirectly.checker_contextvalidates its own shape (unknown namespace, unknown key, or unrecognized backend name all raise) both at task-load time and after the experiment-layer merge, so a typo can't silently no-op an override.BedrockRoute/LiteLLMRouteno longer carrybearer_token/auth_tokenfields. Those secrets now flow only through the sharedcoder_eval.config.settingssingleton, read directly by each consumer (ClaudeCodeAgent._build_sdk_env,judge_bedrock.invoke_bedrock_judge_async) at the point of use — a route object flowing throughCheckContext/environment_info/logging never carries a credential.Full design discussion in #136 (comment).
Review-driven fixes included in this PR
A multi-axis code review surfaced several real issues, fixed here:
resolve_evaluation_route's reuse-agent-route branch shipped a bare, unqualified model alias straight to the Bedrock API when onlychecker_context.api_route.modelwas overridden (no backend override). Fixed via a new shared_bedrock_model_pairhelper that also de-duplicates qualification logic previously copy-pasted across three call sites.judge_bedrock.invoke_bedrock_judge_async's missing-bearer-token check was a bareassert, whichhandle_criterion_errors(_async)was silently catching and downgrading to a scored0.0instead of escalating toFinalStatus.ERROR. Now raisesJudgeInfrastructureError.llm_judgewithroute: litellm, which has no transport implemented yet — copy-pasting it would fail immediately. Fixed toroute: bedrock, with the LiteLLM gap called out explicitly.Orchestrator._eval_route_overridesnow returns aNamedTupleinstead of a bare 2-tuple (closes a silent positional-swap risk); the two duplicated route-resolution call sites inOrchestrator._setupare now one_resolve_routes()helper;environment_infonow recordseval_modelso a judge-model override is visible in run artifacts.Known follow-up (not fixed in this PR — flagging transparently)
checker_context/route-override mechanism has no dedicated test coverage yet (the diff's test-file changes are mechanical adaptations to thebearer_token/auth_tokenfield removal, not new tests). Recommend adding coverage forresolve_evaluation_route's override paths andllm_judge'sroute.modelfallback before relying on this in CI.agent_judgedoes not currently honorchecker_context.api_route.model(its sub-agent's model comes from a hardcoded default in_default_judge_agent_config) — documented in the guide as a known gap rather than fixed, since the fix touches files outside this diff's scope.route: litellmresolves cleanly but fails at grading time with a clear "not implemented yet" error.Test plan
make format/make check(ruff) — cleanuv run pyright— 0 errorsmake lint(custom architectural rules incl. CE030 doc-schema-parity) — cleaneu.anthropic.claude-opus-4-6-v1now correctly qualified, was previously bare)🤖 Generated with Claude Code