feat: pre-adoption shadow eval for the auto-router (blind pairwise judge, per-tier win rates) - #36250
feat: pre-adoption shadow eval for the auto-router (blind pairwise judge, per-tier win rates)#36250tin-berri wants to merge 34 commits into
Conversation
…eton)
Core components:
- Added LiteLLM_ShadowEvalJob and LiteLLM_ShadowEvalVerdict tables to schema
- Migration: 20260807000000_add_shadow_eval_job (SQL for both tables + indexes)
- Types: StartShadowEvalRequest, GetShadowEvalJobResponse, etc. (pydantic models)
- ShadowEvalLogger: CustomLogger integration that will fire background shadow tasks
TODOs for next phase:
1. Wire config parsing (read shadow_eval from model_info or key settings)
2. Implement router call (_call_router_for_shadow) to get classifier tier + model
3. Build endpoints (POST /auto_router/shadow_eval/start, GET .../job/{id})
4. Implement verdict accumulation + per-tier result binning
5. Hook ShadowEvalLogger into proxy initialization
Still unresolved (implementation details):
- Cost estimation accuracy (judge model pricing per region)
- Sub-sampling strategy for high-traffic keys
- Job lifecycle: incremental verdict writes vs batch
- Retry logic for failed router/judge calls
Co-Authored-By: Claude <noreply@anthropic.com>
Backend: - ShadowEvalLogger: background task on async_log_success_event, deterministic sampling, blind pairwise judge, verdict persistence + job counter updates - Endpoints: POST /auto_router/shadow_eval/start (cost estimate), GET /job_id (per-tier results), GET (list), POST /job_id/stop - Schema + migrations: LiteLLM_ShadowEvalJob, LiteLLM_ShadowEvalVerdict tables - Types: StartShadowEvalRequest, GetShadowEvalJobResponse, ShadowEvalResult, ShadowEvalTierResult - Proxy wiring: logger registered in cost_tracking() UI: - useShadowEval.ts: hooks for start/stop mutations + job query with live polling - ShadowEvalSection.tsx: consent-gate form, job status badge, per-tier results table - Integrated into AutoRouterBenchmarksTab alongside existing benchmarks - OpenAPI schema regenerated to include shadow_eval endpoints Tests: unit tests for logger sampling/unmasking/verdict-parsing Linting: all files python3.11 syntax-check + tsx lint ready This ships the full pre-adoption evaluation flow: 1. User starts job: specifies key, router, sampling %, gets upfront cost estimate 2. Sampled requests duplicated through router, judged blind, verdicts persisted 3. Dashboard shows per-tier win rates, cost tracking, live progress 4. Ready for Tyler/Tinder/Access Group pre-launch sign-off Co-Authored-By: Claude <noreply@anthropic.com>
Greptile SummaryThe PR adds pre-adoption auto-router shadow evaluation across persistence, management APIs, detached shadow/judge execution, spend attribution, and the dashboard.
Confidence Score: 4/5The PR should not merge until the sampled success callback no longer performs a spend lookup that can reach the database inline. The active-job lookup was successfully moved to a detached snapshot refresh, but sampled requests still await key and team spend checks whose fallback path can query the database from the success callback. Files Needing Attention: litellm/integrations/shadow_eval_logger.py
|
| Filename | Overview |
|---|---|
| litellm/integrations/shadow_eval_logger.py | Implements the detached evaluation pipeline and snapshot lookup, but retains an inline spend check that can reach the database from the success callback. |
| litellm/litellm_core_utils/internal_call_metadata.py | Adds sanitized attribution metadata for internal router and judge calls while removing parent budget reservations. |
| litellm/proxy/management_endpoints/auto_router_endpoints.py | Adds authorized shadow-evaluation lifecycle and result endpoints with cost estimation and aggregate reporting. |
| litellm-proxy-extras/litellm_proxy_extras/migrations/20260807000000_add_shadow_eval_job/migration.sql | Adds job and verdict persistence, including a partial unique index enforcing one active job per key. |
| ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx | Correctly renders the shadow-evaluation section outside benchmark loading, error, and empty-state returns. |
| ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx | Adds the start form, running-job cards, polling results, tier summaries, and spend presentation. |
Reviews (4): Last reviewed commit: "feat(ui): lead shadow eval results with ..." | Re-trigger Greptile
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 4 · PR risk: 0/10 |
- Registry check now scans all pre-routing strategy registries (auto, complexity, adaptive, quality), not just auto_routers. Fixes 400 on adoption for complexity-router or adaptive-router users. - Split auth into _require_admin_viewer (GET) and _require_admin_writer (start/stop); view-only admins can no longer initiate paid work (judge calls). - request_count UPDATE buffering: in-memory counter flushed every 10s instead of one UPDATE per request. High-traffic keys now cost one DB op per flush interval, not per request. - Default judge model: anthropic/claude-sonnet-5 (was unmapped claude-3-5-sonnet). Cost estimation now prices correctly; fallback is no longer needed. - completion_cost error handling: try/except around litellm.completion_cost() so unmapped judge models don't crash the verdict write. - UI: ShadowEvalSection now always renders (pre-adoption keys have no router sessions yet but still show the start form). Added judge_model parameter to the start form. Fixed accessToken undefined in AutoRouterBenchmarksTab. Tests: - test_shadow_eval_logger.py (26 tests): sampling, verdict parsing, unmasking, skip logic, metadata isolation. - ShadowEvalSection.test.tsx (7 tests): form, active job display, per-tier results, low-sample flagging, completed job handling. - All existing auto-router endpoint tests (22) and component tests (96) pass. Co-Authored-By: Claude <noreply@anthropic.com>
- Forward the original request's non-default params (temperature, tools, response_format, etc.) to the shadow router call. Previously only model and messages were sent, so a request with tools or a non-zero temperature was judged against a shadow response generated under totally different sampling settings -- an unfair, biased comparison. stream and metadata are still stripped: the shadow call needs the full text back and must not leak the caller's own metadata. - Fix unbounded background task backlog: asyncio.create_task() fired unconditionally and only the task body waited on a semaphore, so a traffic spike queued unlimited tasks (each holding a copy of messages/response) before any of them ran. Now the in-flight count is checked and incremented before scheduling; over capacity, the sample is dropped instead of queued. - Fix stopped jobs being silently reactivated: the verdict-write counter update unconditionally set status='running', so a pipeline that started before stop_shadow_eval_job() marked the job 'completed' could overwrite that back to 'running' after the fact. Now it's a conditional update_many scoped to status in (pending, running), so a completed job can never transition back. - Strip comments/docstrings added during the previous fix pass per CLAUDE.md's no-new-comments rule (flagged by review bot). Tests: 5 new regression tests (param forwarding, stream/metadata stripping, backlog cap drops samples under saturation, backlog cap schedules+decrements under capacity, stop-race status guard). 31/31 passing. Co-Authored-By: Claude <noreply@anthropic.com>
Resolves two real conflicts (the PR's actual base branch is litellm_internal_staging, not main): - litellm/types/management_endpoints/auto_router_endpoints.py: kept both the Mapping and Literal imports, both used by pre-existing types. - tests/e2e/proxy_client.py: kept upstream's more detailed create_model() docstring covering multi-replica propagation. Everything else auto-merged cleanly. Regenerated the OpenAPI schema (schema.d.ts) to pick up upstream's tier_turns addition to the auto-router benchmarks response. Co-Authored-By: Claude <noreply@anthropic.com>
Base branch moved again while resolving the prior merge. Same conflict shape in litellm/types/management_endpoints/auto_router_endpoints.py: kept both the Mapping and Literal imports. Regenerated schema.d.ts to pick up upstream's ModelInfo pricing field changes. Co-Authored-By: Claude <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- Remove unused imports (datetime, timezone, ModelResponse) flagged by ruff. - Update test_cost_tracking_adds_two_callbacks_when_prisma_set to expect 2 callbacks on litellm.callbacks (not 1): ShadowEvalLogger now registers alongside _ProxyDBLogger in cost_tracking(). Test name already said 'two', now it actually tests for the correct count. - Format ShadowEvalSection.tsx/.test.tsx per prettier. Tests: 94/94 passing (lifecycle, shadow-eval, auto-router endpoints). Lint: ruff + prettier all clean. Co-Authored-By: Claude <noreply@anthropic.com>
The column was declared Float? with no default, so every row started NULL. The verdict writer increments it, and NULL + x is NULL in Postgres, meaning judge spend never accumulated and the UI always showed no spend. Makes the column non-null with a default of 0 across all three schema copies and the migration, and tightens the response model to a plain float.
|
All four are fixed now. The first three landed before your run; cost_actual was real, the column now defaults to 0 so increments accumulate. |
The lint job's strict-rule budget flagged 17 new violations. Rather than raise the ceiling, this types the code properly: - validate the judge verdict into a PairwiseVerdict pydantic model at the parse boundary instead of dict[str, Any] + cast, which also removes the defensive float()/str() coercion downstream - replace the untyped job dict with a frozen ActiveShadowEvalJob dataclass - validate the prisma job row into _ShadowEvalJobRow, replacing 11 no-op '# type: ignore[attr-defined]' comments - annotate the success hook and drop Any from the remaining signatures - mark the genuine third-party dict shapes (prisma filters/payloads, SDK message lists) with '# mutable-ok' reasons per the existing convention
Three fixes from the live end-to-end run: - The judge ran with max_tokens=200, which truncated roughly 12% of verdicts mid-JSON so they were lost to failed_count. Raise it to a named JUDGE_MAX_OUTPUT_TOKENS=500 and price the upfront cost estimate off the same constant, so the estimate can't silently drift from what the judge is actually allowed to emit. - The UI only ever rendered the newest job, so starting a new eval hid the results of a populated older one. Prior jobs are now listed in a collapsible 'Previous evaluations' card, each expandable to its own per-tier results. - Move the shadow eval section above the benchmarks body: pre-adoption keys have no router sessions, so it was buried under an empty state. It stays outside BenchmarksBody so it survives that early return. Co-Authored-By: Claude <noreply@anthropic.com>
Shadow and judge calls were fired with no caller identity on their metadata. The proxy's cost callback requires user_api_key/_team_id/... to log spend and apply budget checks, and silently drops the entry without them — so an admin-enabled eval billed real provider spend that landed on no key, no team, and no budget counter, invisible to every limit the shadowed key is normally subject to. Extract the identity-forwarding rules the auto-router classifier already implements into a shared litellm/litellm_core_utils/internal_call_metadata.py: forward the caller's identity subset, strip the parent's budget reservation (top-level and the copy nested in user_api_key_auth) so a sub-call can't finalize a reservation that belongs to the parent, and stamp the sub-call's origin. The classifier now uses this module instead of its own copy. Wire both shadow eval call sites (_call_router_shadow, _call_judge) through it, and add a per-job spend cap (job cost_actual >= max(3x the quoted estimate, $0.50)) so a bad estimate or a traffic spike can't turn a quoted eval into a runaway bill; a capped job self-completes and is evicted from cache so it can't be resurrected by an in-flight request. Also surface api_key_id/team_id on GetShadowEvalJobResponse so an admin running several jobs can tell which key's traffic a given win rate belongs to, and regenerate the dashboard's OpenAPI types for the new fields. Co-Authored-By: Claude <noreply@anthropic.com>
…hadow-eval-pre-adoption
A shadow eval samples ongoing traffic, so a job without an end date keeps billing judge calls until someone remembers to stop it — and the upfront estimate silently priced exactly one week regardless. Jobs now take a duration_days (1-30, default 7): the start endpoint stamps ends_at, the estimate scales trailing volume to the requested window, and the logger completes a job past its window through the same guarded update + cache eviction path as the spend cap (generalized into _finalize_job). The existing shadow eval migration is amended in place since it has not shipped anywhere yet. The start form no longer asks anyone to paste a key hash: the key is a type-to-search combobox backed by /key/list alias substring search that submits the token, the auto-router is a filter-as-you-type combobox fed by the configured auto-router deployments, and duration is a select. Active job cards show when the job will end. The judge model field is now labelled as such, with guidance: judging two answers blind needs solid comprehension and reliable JSON, not frontier reasoning — a mid-tier model (Claude Sonnet / GPT-4o class) is recommended, nano/mini-class judges give unreliable verdicts, and frontier reasoning models add cost without changing outcomes. Same guidance mirrored into the API field description. Co-Authored-By: Claude <noreply@anthropic.com>
…budget Adds a "Shadow eval" button next to the Auto-router usage heading that smooth-scrolls to the shadow eval section, so it's reachable without scrolling past the benchmarks body first. Also fixes a PR review comment (veria-ai): shadow and judge calls ran outside the normal auth path, so they never went through reserve_budget_for_request and could push an already-exhausted key or team further over budget before their own spend was even recorded. _key_or_team_is_over_budget reads the same cross-pod spend counters that path reserves against (via the existing get_current_spend) and skips the shadow/judge pair outright when the shadowed key or its team is already at or over budget. This is a read-time check, not a reservation — appropriate for a best-effort background measurement task, not a billed user request — so it narrows the window rather than closing it against concurrent bursts, which the response comment explains. Co-Authored-By: Claude <noreply@anthropic.com>
…hadow-eval-pre-adoption
…hadow-eval-pre-adoption # Conflicts: # ui/litellm-dashboard/tsconfig.tsbuildinfo
…task greptile: _key_or_team_is_over_budget awaited get_current_spend inline in async_log_success_event, and that read can fall back to an authoritative DB query — the same request-path DB touch the job-snapshot rework just removed. The gate now runs first inside _run_shadow_eval, where a detached task absorbs the latency and the paid shadow/judge calls still never fire for an over-budget key. The callback is back to zero awaits beyond its own bookkeeping; a regression test asserts get_current_spend is never awaited before the callback returns. Co-Authored-By: Claude <noreply@anthropic.com>
Upstream 255d651 gated the view's tabs behind useCan, which chains useAuthorized -> useIsOrgAdmin -> useOrganizations -> useQuery, so the two view test files now die with 'No QueryClient set' before asserting anything. Mock useIsOrgAdmin (and useAuthorized in the activity test) at the boundary the component actually reads; the tests assert tab behavior, not org-admin resolution. These fail on staging's own full suite runs too — this fixes the slice that our PR's related-tests job runs. Co-Authored-By: Claude <noreply@anthropic.com>
…36321) A shadowed key's real traffic can be a mix of models, and per-tier win rates blend those incumbents together: if the router beats gpt-4o but loses to a fine-tune within the same tier, the tier rate hides both facts. The verdict rollup now groups by (tier, real_model) and the results carry a second stratification, by_current_model, listing win rates against each model the key actually uses. The UI renders it as a second table, only when the traffic really was mixed — a single incumbent would just repeat the tier table's totals. Tier confidence is now turn-weighted across the merged rows rather than averaged per row. Requests already served by the router being shadowed are now skipped before sampling: duplicating them compares the router to itself — guaranteed ties, judge spend for zero information. Traffic routed by a *different* auto-router still samples, which is a meaningful router-vs-router comparison. Skipped requests still count toward the job's request_count so the seen/judged ratio stays honest. Co-authored-by: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
…trics, loop-owned job lifecycle
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Score: 3.5 / 5 What works wellArchitecture is sound. The core design decisions hold up under scrutiny:
Real concerns1. Logger registration is not idempotent (
Fix: guard with an isinstance check before registering, similar to what def _register_shadow_eval_logger() -> None:
from litellm.integrations.shadow_eval_logger import ShadowEvalLogger
if any(isinstance(cb, ShadowEvalLogger) for cb in litellm.callbacks):
return
litellm.logging_callback_manager.add_litellm_callback(ShadowEvalLogger())2.
3. Unused schema fields
4. PostgreSQL-specific raw SQL without annotation
SummaryThe feature is thoughtfully designed and the critical-path safety properties (no latency added, no recursion, no unbounded backlog) are correct. The 3.5 reflects that the registration/lifecycle wiring has a real correctness bug (non-idempotent registration) and a fragile startup dependency that will silently break if startup order ever changes — both fixable in a few lines before merge. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ec32f70. Configure here.
…ct row's real-side columns
|
Addressed the review in 59ae51c. Finding 2: registration now owns the lifecycle loop start and the separate startup step is deleted, so no ordering exists to break. Finding 3: shadow_request_id and real_response_tokens are now written on every verdict. Finding 1 does not reproduce: the callback manager dedups CustomLogger instances by class (_add_custom_logger_to_list), so a second cost_tracking() discards the new instance; pinned by test_cost_tracking_twice_registers_one_shadow_eval_logger rather than duplicating that dedup at the call site. Finding 4: the proxy schema is PostgreSQL-only and AUTOROUTER_BENCHMARKS_SQL in the same file already uses the same casts, so the raw SQL matches repo practice |
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 59ae51c. Configure here.
|
Based on a thorough review of the diff, here's my assessment: Score: 4 / 5 This is a well-engineered, substantial feature. Here's what earns that score: What's done well:
Concerns that keep this from 5/5:
prisma: Final = self._prisma_provider()
# ... budget check uses prisma via get_current_spend ...
shadow: Final = await self._call_router_shadow(...) # spend incurred
verdict: Final = await self._call_judge(...) # spend incurred
if prisma is None: # ← checked too late
returnThe
|
|
Addressed in e51008f. Concern 1: the prisma gate now sits above the shadow and judge dispatch, so no provider spend happens without a place to record the verdict (pinned by a mutation-checked test). Concern 2: the list endpoint takes a bounded limit query param (default 50, max 200). Concern 3 stands as designed: overshoot is bounded by one 10s tick times the 16-task cap, a few cents at judge prices, and the tick already sets stop propagation. Concern 4 does not reproduce: add_litellm_callback appends to litellm.callbacks via _safe_add_callback_to_list, and test_registration_starts_the_lifecycle_loop_on_the_registered_instance fails if that routing ever changes |
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e51008f. Configure here.
|
Score: 3/5 The core architecture shows careful engineering — zero-latency detached tasks, hash-based deterministic sampling, recursion guard via What pulls it down: 1. Judge prompt can reach ~48k chars (correctness/cost bug) 2. Lifecycle loop start path is fragile 3. Migration amended in-place 4. Staging pass explicitly not done 5. Judge model validation is shallow The architecture is solid enough that fixing (1) and (3) + completing staging would make this mergeable. As-is it has a real correctness bug in judge prompt sizing and an operational risk in the migration strategy. |
… 0.4.84 -> 0.4.84.post1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
550b1aa to
7254ff9
Compare
What
Pre-adoption shadow evaluation for the auto-router: answer "is it safe to turn the router on?" with evidence from the customer's own live traffic, before any production traffic is routed.
How
Backend
LiteLLM_ShadowEvalJob+LiteLLM_ShadowEvalVerdicttables, plus migration20260807000000_add_shadow_eval_job; verdict rows record ids and completion-token counts for BOTH arms, so verdicts can later be checked for judge verbosity bias and joined to the shadow call's spend logShadowEvalLogger(litellm/integrations/shadow_eval_logger.py): hooksasync_log_success_event; the hook is one dict lookup against a job snapshot and never touches the DB; deterministic hash-based sampling, so the same request samples the same way across pods; caps in-flight shadow pipelines at 16 per pod by shedding rather than queueing, so a traffic spike can't build an unbounded task backlog; skips any request stampedinternal_call_origin, so it never recurses on its own shadow/judge calls and never samples the auto-router's classifier sub-calls as if a user sent themrequest_countfreezes when a job stops (status-guarded writes), and a job whose window or judge-spend cap has passed is completed even if its key goes quietjudge_modelis a configured deployment (DB-stored credentials work) and through the SDK for provider-qualified public names; start rejects a judge name neither path can resolve (400) instead of accepting a job that fails every turn, and the most recent shadow/judge failure is persisted aslast_erroron the job and shown in the UIapi_requestsin the daily rollups, no longer accrueautorouter_savings_spend, and are excluded from theLiteLLM_AutoRouterSessionbenchmarks rollup, so a running eval cannot inflate the adoption metrics or its own next cost estimateauto_router_endpoints.py:POST /auto_router/shadow_eval/start: validates the router is a configured pre-routing strategy and the judge model is resolvable (a deployment prices by its underlying provider model), rejects a second active job per key (409), returns an upfront judge-cost estimate priced from the key's trailing 7-day user-request volumeGET /auto_router/shadow_eval/{job_id}: counters and per-tier win rates (SQL aggregate over verdicts)GET /auto_router/shadow_eval: list jobsPOST /auto_router/shadow_eval/{job_id}/stop: halt sampling (~10s propagation), keep verdictsPROXY_ADMIN; the read endpoints also allowPROXY_ADMIN_VIEW_ONLYcost_tracking(), which already owns the same prisma dependencyUI (Cost Optimization -> Auto-Router Usage)
ShadowEvalSection: start form (key, router, %, judge model, upfront cost estimate warning), live-polling job card, per-tier results table with low-sample warnings, running judge spenduseShadowEval.tshooks; OpenAPIschema.d.tsregeneratedScope / known limitations
request_count(up to a tick of tail counts on other pods is dropped rather than written after stop)Test plan
tests/test_litellm/integrations/test_shadow_eval_logger.pycovering deterministic sampling, A/B unmask mapping, fence-tolerant verdict JSON parsing, success-hook skip paths (including classifier sub-calls), request-parameter forwarding, the in-flight backlog cap, router-first judge dispatch,last_errorpersistence, and the lifecycle-loop flush/finalize/status-guard behaviors, each mutation-checked against the unfixed codeLiteLLM_AutoRouterSession, and internal calls keeping spend while not counting as requestsShadowEvalSection.test.tsx(failure banner included), plus the existingAutoRouterBenchmarksTabsuite20260807000000_add_shadow_eval_jobwas amended in place (addslast_error, drops the never-readresult_json), so a DB that already applied it needs a re-resolve ordb pushNote
High Risk
Background duplicate LLM traffic bills the shadowed key and touches spend rollups, budget checks, and multi-pod job lifecycle; mis-attribution or metric inflation would affect billing and adoption dashboards.
Overview
Adds pre-adoption shadow evaluation so admins can sample a virtual key’s live traffic, replay it through an auto-router in the background, and compare answers with a blind LLM judge—without serving shadow responses to users.
Data & control plane: New
LiteLLM_ShadowEvalJob/LiteLLM_ShadowEvalVerdictschema and migration, including a partial unique index so only one active job exists per key across pods. Admin endpoints under/auto_router/shadow_evalstart jobs (with cost estimate from daily rollup volume), list/get aggregated win rates by tier and incumbent model, and stop jobs.Runtime:
ShadowEvalLoggerregisters withcost_tracking(), samples deterministically on successful completions, runs shadow + judge in detached tasks (concurrency cap, budget and per-job spend/time stops), and uses a 10s lifecycle loop for job snapshots, counter flushes, and auto-completion. Sharedinternal_call_metadatahelpers stamp shadow/judge/classifier sub-calls; daily spend rollups and auto-router session benchmarks exclude those calls from request/savings metrics while still billing spend to the shadowed key.UI: Cost Optimization’s auto-router tab gains
ShadowEvalSection(start form, live results, jump link from benchmarks).Reviewed by Cursor Bugbot for commit e51008f. Bugbot is set up for automated code reviews on this repo. Configure here.