feat(auto-router): scope shadow eval jobs to multiple keys - #36871
feat(auto-router): scope shadow eval jobs to multiple keys#36871tin-berri wants to merge 1 commit into
Conversation
Greptile SummaryThe PR extends auto-router shadow evaluation from one virtual key per job to multiple independently budgeted keys.
Confidence Score: 4/5The rolling-deployment schema incompatibility must be fixed before merging because the pre-upgrade migration leaves serving old pods querying columns that no longer exist. The feature paths are coherently updated for per-key state, but dropping the legacy columns before the new Deployments roll out causes current shadow-eval reads and writes from old pods to fail during a standard Helm upgrade. Files Needing Attention: litellm-proxy-extras/litellm_proxy_extras/migrations/20260813000000_shadow_eval_multi_key/migration.sql
|
| Filename | Overview |
|---|---|
| litellm-proxy-extras/litellm_proxy_extras/migrations/20260813000000_shadow_eval_multi_key/migration.sql | Introduces and backfills per-key child state, but its immediate legacy-column removal breaks old pods during the default pre-upgrade rolling deployment and lacks the required removal rationale. |
| litellm/integrations/shadow_eval_logger.py | Changes active-job caching, budget accounting, and attempt attribution to operate independently per key. |
| litellm/proxy/management_endpoints/auto_router_endpoints.py | Updates shadow-eval creation, filtering, stopping, sweeping, and result aggregation for multi-key jobs. |
| litellm/types/management_endpoints/auto_router_endpoints.py | Replaces the single-key request and response contract with deduplicated key collections and derived multi-key status. |
| ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx | Adds multi-key job creation, aggregate budget display, and a per-key results view. |
| ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.tsx | Adds a paginated multi-select that preserves selected options across searches and pages. |
Reviews (1): Last reviewed commit: "feat(auto-router): scope shadow eval job..." | Re-trigger Greptile
| ALTER TABLE "LiteLLM_ShadowEvalJob" DROP COLUMN "api_key_id", | ||
| DROP COLUMN "max_turns", | ||
| DROP COLUMN "stopped_at"; |
There was a problem hiding this comment.
Legacy columns break rolling upgrades
During the default Helm upgrade, this pre-upgrade migration drops api_key_id, max_turns, and stopped_at while old proxy pods are still serving. Those pods continue reading and writing these columns, causing shadow-eval sampling and management operations to fail with missing-column database errors until the rollout completes.
Knowledge Base Used: Database Schema and Proxy DB Access Layer
There was a problem hiding this comment.
Fixed. Migration is expand only now: legacy columns and the old index stay, plus a backfill. Proved against real Postgres in the PR body
There was a problem hiding this comment.
Correction to my last reply: the drops are back. Shadow eval ships in no tag, so no pod reads those columns. Proof in body
There was a problem hiding this comment.
That's a fair correction. If shadow eval has never shipped in a tagged release, there are no deployed pods reading those columns and the rolling-upgrade concern doesn't apply. The destructive path is fine in that case.
One thing worth adding: a brief SQL comment in the migration explaining why the immediate drops are safe (e.g. -- safe: shadow eval has not shipped in any released tag; no deployed pod reads these columns) would protect the next person who sees bare DROP COLUMN statements in what otherwise looks like an expand-only migration from raising the same flag.
There was a problem hiding this comment.
No release tag contains the shadow eval commits, so no old pod exists. git ls-tree on v1.98.0-dev.2 and v1.96.2 shows zero shadow_eval files
| ALTER TABLE "LiteLLM_ShadowEvalJob" DROP COLUMN "api_key_id", | ||
| DROP COLUMN "max_turns", | ||
| DROP COLUMN "stopped_at"; |
There was a problem hiding this comment.
Document the removed job columns
These destructive statements do not document that the three fields were migrated into LiteLLM_ShadowEvalJobKey. Recording that rationale beside the removal preserves the data-movement dependency for future migration work.
| ALTER TABLE "LiteLLM_ShadowEvalJob" DROP COLUMN "api_key_id", | |
| DROP COLUMN "max_turns", | |
| DROP COLUMN "stopped_at"; | |
| -- Key scope, per-key budgets, and stop state were migrated to LiteLLM_ShadowEvalJobKey. | |
| ALTER TABLE "LiteLLM_ShadowEvalJob" DROP COLUMN "api_key_id", | |
| DROP COLUMN "max_turns", | |
| DROP COLUMN "stopped_at"; |
Rule Used: What: Column deletions in migrations or database s... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Moot, those drops are gone. This repo's CLAUDE.md bans explanatory comments, migrations included, so the rationale lives in the PR body instead
There was a problem hiding this comment.
Correction: the drops are back after the revert. CLAUDE.md bans explanatory comments in migrations, so the rationale stays in the PR body
There was a problem hiding this comment.
Acknowledged — I'll withdraw the suggestion. The drops are present and the rationale is documented in the PR body, which is the right place under your no-comments policy.
Shall I remember this as a rule: "CLAUDE.md's no-comments policy extends to SQL migration files; rationale for destructive statements (column drops, index drops) belongs in the PR body, not inline comments"?
There was a problem hiding this comment.
Added the rationale in the migration: the dropped columns never shipped in any tag, so no deployed pod reads them
There was a problem hiding this comment.
The comment at the bottom of the migration covers both points — it records where the data moved and explains why the same-migration drop is safe. Original concern is resolved.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
d36e54b to
369995b
Compare
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Score: 4/5 The engineering quality is high. Here's the breakdown: What's strong:
What gives me pause:
None of these are blockers on their own, but items 1 and 2 together are enough to keep this from 5/5. |
|
Thanks for the detailed 4/5 — all points noted:
|
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: 1 · PR risk: 0/10 |
369995b to
815923b
Compare
|
Review points closed: breaking API is fine since nothing ships it, the sweep is one ANY() query, nullable columns and unattributed bucket gone @greptileai can you reply here with a score and why that score? |
|
Score: 4.5 / 5 Here's the breakdown: What's strong:
Minor concerns keeping it from 5:
Neither is a regression or correctness issue. The architecture is sound, the constraint enforcement is at the DB level, and the live proof is unusually thorough for a feature PR. The 0.5 gap is the over-broad count query and the non-transactional stop pattern. |
815923b to
ec03a6a
Compare
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Score: 4/5 The engineering here is solid. Here's the breakdown: What's done well:
What gives me slight pause (not blockers):
The migration proof in the PR description (fresh-DB deploy diff against 4/5 — the design is sound and the implementation is correct. The two minor points above are things to document or note, not things to fix before merge. |
ec03a6a to
ee41046
Compare
|
Concern 1 actioned with a direction-injection comment. Concern 2: stop filters stopped_at IS NULL, so concurrent stops converge. Concern 3 is deliberate, as noted |
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Score: 4/5 This is a well-engineered feature extension. Here's the breakdown: Why it earns a 4: Architecture is correct. Moving Race safety is preserved and tightened. The partial unique index on Budget isolation is correct end-to-end. Migration is clean. Create child table → backfill from job rows → add Tests are substantive. The new Why it doesn't reach a 5: The attempt count filter in where={"job_id": {"in": job_ids}, "api_key_id": {"in": key_ids}}This matches any attempt where The stop endpoint does two round-trips with a narrow race window. No automated test covers the DB-level uniqueness constraint. The mock-based tests exercise the advisory read-then-create path, but the partial unique index is the actual last line of defense for concurrent starts. The live proof covers this, but CI doesn't. These are genuine minor issues rather than blockers. The feature is ready for a maintainer review. |
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 ee41046. Configure here.
ee41046 to
10bc9cb
Compare
|
Exact-pair OR filter replaces the cross-product, with a pin test. Stop stays idempotent via stopped_at, and the index is proven live |
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Score: 5/5 This is a well-engineered feature extension with correct design decisions at every layer. Here's why: Schema and migration — Moving Attempt count query — Using Budget isolation — Status derivation — Sweep SQL — Tests — The The only minor items worth noting:
Neither is a blocker. The PR is correct, the design is sound, and the proof of work is exceptional. |
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 10bc9cb. Configure here.
A shadow eval job now covers a set of keys instead of exactly one, and each key carries its own max_turns budget, so one key exhausting its budget leaves its siblings sampling. Key, budget and stop state move to a new LiteLLM_ShadowEvalJobKey child table, taking the one-active-per-key partial unique index with them so the race-safe DB guarantee is unchanged. Attempts now record api_key_id, which is what makes per-key turn counting and the new by_key result slice possible The dashboard picker becomes a multi-select and the detail view gains a per key breakdown showing each key's own budget and status
10bc9cb to
4360c9f
Compare
|
Rebased onto latest staging, resolving conflicts with the reverse-mode and multi-surface sampling work that landed since |
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Score: 4/5 What's excellent (why not lower):
Two things holding it back from a 5:
|
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 4360c9f. Configure here.
|
Superseded by #37251, which rebuilds this on the current schema with an additive group_id column and no sampler changes |
TLDR
Problem this solves:
How it solves it:
User Flow
Before: an admin shadow testing an auto-router across two of their keys can only cover one key at a time, so no single result describes the router
"api_key_ids": ["<hash A>", "<hash B>"]and get back 422 sayingapi_key_idis requiredAfter: the same admin covers both keys with one job and reads one result
"api_key_ids": ["<hash A>", "<hash B>"]and get back 201 with a single job id listing both keys, each with its ownmax_turnsRelevant issues
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Type
🆕 New Feature
Changes
A shadow eval job now holds a list of keys instead of one. Job-wide settings (router, direction, judge model, shadow percentage, window) stay on the job, while each key carries its own sample budget and its own stop timestamp in a child row. Sampled attempts record which key they came from, which is what makes both the per key budget and the per key result slice exact rather than estimated
The DB-enforced uniqueness guarantee moves with it, and keeps the direction dimension that #36865 added. The partial unique index now sits on the child table over
(api_key_id, direction) WHERE stopped_at IS NULL, so two concurrent starts naming the same key in the same direction still cannot both win, with no read-then-write in between, while a forward and a reverse job over the same key remain independent slots. Holding direction on the child row means the child could otherwise drift from its parent, so the foreign key is composite over(job_id, direction)and the database rejects a key row claiming a direction its job does not haveJob status stays derived rather than stored. A job reads completed once its window passes, stopped only when every key has ended, and running while any key is still sampling, so one key finishing early never hides its siblings
The migration moves those three columns rather than leaving them behind. It creates the child table, backfills every existing job into a key row carrying its direction, backfills each existing attempt with its job's key before that column goes NOT NULL, then drops
api_key_id,max_turnsandstopped_atoff the job. Shadow eval has never been in a release, so no deployment runs the single key code and there is no mixed version window to design aroundScreenshots / Proof of Fix
Live proxy on localhost:4000, real billed LLM calls, no mocks and no pytest. The run below was captured at ec03a6a. Everything committed since answers review findings without touching the paths it exercises: migration comments, an attempt count query narrowed to exact job and key pairs, which only drops groups nothing read, and a UI only move of the per key table. The discriminator is step 7: two keys at a budget of 2 judge 4 turns, where a shared pool would have stopped at 2. Rounds 3 and 4 send real traffic on both keys and add nothing, which is the budget holding rather than the traffic running out
Each round sends two kinds of request per key because forward and reverse sample opposite traffic. A forward job skips what its own router served, since duplicating it would compare the router to itself, so plain
haiku-4-5calls feed the forward job andauto_router1calls feed the reverse one. Steps 6 and 9 show the two directions holding separate slots for the same pair of keysFor the UI, with the same proxy running and
npm run devinui/litellm-dashboard:Migration
An earlier revision kept the three job columns and made them nullable so that a pod on the previous image could keep serving mid rollout. That answered Greptile's flag, and it cost a NULL branch in the per key budget, an
unattributedbucket in the API response, and three dead columns nothing reads. The window it guards cannot occur:git tag --containson the shadow eval commit returns nothing, and neither the latest stable release nor the current dev pre-release ships the shadow eval migration orshadow_eval_logger.py, so no pod anywhere runs the single key code. The migration is destructive again and all three guards are gone with itThe run below applies it to a database already holding jobs and attempts in both directions, checks the backfill and the moved constraints, then deploys the whole chain to a fresh database and diffs the result against schema.prisma
Row 4 of that run is the case worth naming:
hash_aheld an active forward job and an active reverse job before the upgrade, and comes out the other side holding two independent key rows with their own budgets, which is the shape the rest of this PR relies onCaveats
Local direct provider credentials were dead on this rig, so the three model groups under test pointed at an OpenAI-compatible gateway upstream. The calls are real and billed, and the router really picked among them, though the provider path is one hop longer than a direct provider run. A direct provider re-run is owed before this is treated as covering provider-specific behavior
judge_spendreads 0 above for the same reason: the substituted upstream model string is not a key in the price map, so cost lookup returns zero even though the judge calls were billed. Nothing in this change touches cost attributionStep 8 shows the job still reading
runningafter both budgets are spent. Sweeping a finished key row is lazy and scoped, exactly as it was before this change, so status catches up the next time a start touches those keys. Budget enforcement itself is live in the sampling path, which is what rounds 3 and 4 demonstrateFinal Attestation
I ran the flow end to end against a live proxy with real provider calls, and the output above is that run verbatim, including the status caveat rather than a cleaned up version. Tests were extended in the mapped files rather than added as new shallow ones, and they fail if the per key budget or the one active job per key constraint breaks
Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Note
Cursor Bugbot is generating a summary for commit ee41046. Configure here.