Skip to content

feat: pre-adoption shadow eval for the auto-router (backend) - #36571

Closed
tin-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_shadoweval_backend
Closed

feat: pre-adoption shadow eval for the auto-router (backend)#36571
tin-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_shadoweval_backend

Conversation

@tin-berri

@tin-berri tin-berri commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Admins cannot answer "is it safe to turn the auto-router on?" before routing production traffic through it
  • Adopting a router today means flipping real traffic and watching quality complaints, or not adopting at all

How it solves it:

  • A shadow eval job samples a configurable slice of one key's successful chat traffic, duplicates each sampled request through the auto-router in a detached background task (zero added latency, shadow responses never served), and has an LLM judge compare the real and shadow responses blind with A/B labels randomized
  • Verdicts are stratified by the router's own tier classification and by the incumbent model, so the result reads "router matched or beat the current model on 84% of SIMPLE turns, 58% of REASONING turns"
  • Shadow, judge, and classifier sub-calls bill their spend to the shadowed key but are stamped internal_call_origin, which excludes them from api_requests, autorouter_savings_spend, and the LiteLLM_AutoRouterSession benchmarks rollup, so a running eval cannot inflate adoption metrics or its own next cost estimate

User Flow

  1. Admin calls POST /auto_router/shadow_eval/start with a key, a configured auto-router, a sampling percentage, a judge model, and a duration; the response carries an upfront judge-cost estimate priced from the key's trailing 7 day request volume
  2. Traffic flows normally; within one 10s lifecycle tick every pod starts sampling
  3. GET /auto_router/shadow_eval/{job_id} returns counters and win rates by tier and by incumbent model; POST .../stop halts sampling and freezes counters; the job also stops itself at its scheduled end or when judge spend reaches 1.5x the estimate ($1 floor)

Relevant issues

Changes

  • New LiteLLM_ShadowEvalJob and LiteLLM_ShadowEvalVerdict tables; one active job per key is enforced by a partial unique index, so a concurrent start on another pod surfaces as the same 409 as the advisory read
  • New ShadowEvalLogger (litellm/integrations/shadow_eval_logger.py): the success hook is one dict lookup against a lifecycle-loop-refreshed job snapshot; deterministic hash sampling; at most 16 in-flight shadow pipelines per pod, shedding rather than queueing; skips internal sub-calls, self-shadowing, over-budget keys
  • New shared litellm/litellm_core_utils/llm_judge.py owning the fence-tolerant verdict parser, the router-or-SDK judge dispatch, and the router-resolvable predicate; the llm_as_a_judge guardrail now delegates to it instead of carrying private copies, and start-time judge validation calls the same predicate the dispatch uses
  • New shared litellm/litellm_core_utils/internal_call_metadata.py owning identity forwarding and budget-reservation stripping for internal sub-calls; the complexity router's private copy is deleted in favor of it
  • Four endpoints in auto_router_endpoints.py (start, list, get, stop); start and stop require PROXY_ADMIN, reads also allow PROXY_ADMIN_VIEW_ONLY; start rejects a router that is not configured, a judge the dispatch cannot resolve, and an api_key_id that is not a key on this proxy
  • The status-guarded counter update decides whether a verdict lands, so a pipeline finishing after its job stopped stores nothing and a completed job's results always agree with its counts; shadow and judge calls run with retries and fallbacks disabled, so a failed call is a counted miss rather than a spend multiplier
  • Shadow and judge calls inherit the caller's effective turn_off_message_logging, resolved once from the parent's callback kwargs, the same propagation the auto-router classifier already does, so a request opted out of message logging never has its conversation or responses logged in plaintext by the eval's sub-calls

What does not change

Requests on keys with no active job pay one dict lookup in the success hook and nothing else. No existing endpoint, table, or logger changes behavior; the guardrail and classifier refactors are pure delegation with their existing tests untouched and passing

Things a reviewer will ask about

  • The cost estimate reads LiteLLM_DailyUserSpend (a handful of indexed rows per key/day), never LiteLLM_SpendLogs; verdict aggregation is two GROUP BYs over one job's verdicts through the job_id index, so read cost at a million requests is bounded by the job's own verdict count, which the spend cap bounds in turn
  • Verdict rows store only what the results endpoint reads (tier, models, preference, confidence). The reference PR also stored token counts, response ids, judge reasoning, and per-verdict judge model; none had a reader, and review flagged them, so they are dropped rather than populated
  • The status literal is pending | running | completed; nothing writes a failed status, so it does not exist
  • The pre-dispatch budget read is deliberately a soft check, not a reservation: the docstring states it, overshoot is bounded by 16 in-flight pipelines per pod times pod count times one pipeline's cost, and the spend cap check itself lags by at most one 10s lifecycle tick, the sub-calls' spend lands on the same cross-pod counters right after completion, and the per-job spend cap bounds the eval regardless of key budget. Wiring background sub-calls into the request path's reservation lifecycle risks the double-finalization bug the metadata sanitizer exists to prevent

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Live proxy on this branch (port 4214, fresh Postgres with the new migration deployed, stub upstream minting unique response ids; every provider credential in the local .env is out of credit, so upstream calls are stubbed and a real-provider re-run is owed before merge, precedent #30277 / #34029)

Before, at the merge-base (e37ae03), same rig:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4214/auto_router/shadow_eval/start -H "Authorization: Bearer sk-1234" -d '{...}'
404
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:4214/auto_router/shadow_eval -H "Authorization: Bearer sk-1234"
404

After, on this branch. Start a job on a fresh key, with the rejection matrix:

$ curl -s -X POST localhost:4214/auto_router/shadow_eval/start -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"api_key_id":"9f08e2...","router_name":"auto_router1","shadow_percentage":100,"judge_model":"gpt-5-mini","duration_days":1}'
{"job_id":"cmsp3fjtb0000hvrhp8hmc6cl","status":"pending","estimated_request_count":0,"estimated_cost":0.0}

duplicate start -> 409    judge_model "not a model!!" -> 400    virtual (non-admin) key -> denied

12 chat requests on the shadowed key (mix of simple, complex, reasoning prompts), then:

$ curl -s localhost:4214/auto_router/shadow_eval/cmsp3fjtb0000hvrhp8hmc6cl -H "Authorization: Bearer sk-1234"
{
  "status": "running", "request_count": 12, "completed_count": 12, "failed_count": 0,
  "results": {
    "by_tier": [
      {"group": "COMPLEX",   "turn_count": 4, "real_win_rate_pct": 75.0, "shadow_win_rate_pct": 25.0, ...},
      {"group": "REASONING", "turn_count": 3, "real_win_rate_pct": 66.7, "shadow_win_rate_pct": 33.3, ...},
      {"group": "SIMPLE",    "turn_count": 3, "real_win_rate_pct": 66.7, "tie_rate_pct": 33.3, ...},
      {"group": "MEDIUM",    "turn_count": 2, "real_win_rate_pct": 100.0, ...}
    ],
    "by_current_model": [{"group": "openai/gpt-5", "turn_count": 12, "real_win_rate_pct": 75.0, ...}],
    "overall_shadow_win_rate_pct": 16.7, "overall_tie_rate_pct": 8.3
  },
  "cost_actual": 0.001163, "ends_at": "2026-08-12T20:08:54..."
}

Spend attribution and metric exclusion, straight from the rig's Postgres:

        origin         | calls |  spend   | distinct_keys
-----------------------+-------+----------+---------------
 autorouter_classifier |    12 | 0.000221 |             1
 shadow_eval_judge     |    12 | 0.001163 |             1
 shadow_eval_router    |    12 | 0.002038 |             1
 user_request          |    12 | 0.001446 |             1
-- all 48 rows billed to the shadowed key

       model       | api_requests | successful_requests |  spend
-------------------+--------------+---------------------+----------
 openai/gpt-5      |           12 |                  12 | 0.001446   <- user traffic
 openai/gpt-5-mini |            0 |                   0 | 0.001217   <- judge + shadow: spend yes, requests no
 openai/gpt-5-nano |            0 |                   0 | 0.000235
 openai/gpt-5.4    |            0 |                   0 | 0.000770
 openai/gpt-5.5    |            0 |                   0 | 0.001200

 autorouter_session_rows = 0   <- shadow duplicates carry a routing_decision yet never reach the adoption rollup

Stop freezes the job even as traffic continues:

$ curl -s -X POST .../shadow_eval/cmsp3fjtb0000hvrhp8hmc6cl/stop ...
{"status": "completed", "request_count": 12, "completed_count": 12, ...}
stop again -> 400; one more chat request on the key -> request_count stays 12

Type

🆕 New Feature

Caveats (if any)

  • Turn-level only: the shadow never influences the next real turn, so multi-turn compounding effects are out of scope for v1
  • Only chat-shaped traffic (/v1/chat/completions) is sampled; /v1/messages and /v1/responses surfaces are skipped by the call_type gate by design
  • Counters buffer up to one 10s lifecycle tick; a stopped job drops up to one tick of tail counts from other pods rather than writing them after stop
  • Upstream was stubbed in the live proof (all local provider credentials are dead); a staging pass with a live key and real providers is owed before merge

QA runbook

  1. Configure an auto-router (auto_router/complexity_router) plus its tier models and start the proxy with a database
  2. POST /auto_router/shadow_eval/start with {"api_key_id": "<key token hash>", "router_name": "<router>", "shadow_percentage": 100, "judge_model": "<any configured model or provider/model>", "duration_days": 1} as admin; expect 201 with an estimate, 409 on repeat, 400 for an unknown router or unresolvable judge
  3. Send a few /v1/chat/completions requests with the shadowed key, wait ~15s, GET /auto_router/shadow_eval/{job_id}; expect request_count, completed_count and per-tier results to grow
  4. Check LiteLLM_SpendLogs.metadata->>'internal_call_origin' groups spend into shadow_eval_router / shadow_eval_judge rows billed to the shadowed key, and LiteLLM_DailyUserSpend.api_requests counts only the user requests
  5. POST .../stop; expect status completed, counters frozen, verdicts kept

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

High Risk
Bills real shadow/judge spend to customer keys and modifies spend aggregation plus budget-adjacent paths on the request success hook. Large new surface with DB schema, background LLM calls, and metric-exclusion logic.

Overview
Enables admins to pre-evaluate an auto-router against one key's live traffic before adoption. A sampled slice of successful chat requests is duplicated through the router in a detached task; an LLM judge compares real vs shadow responses blind, and win rates are stratified by router tier and incumbent model.

Adds POST/GET /auto_router/shadow_eval* admin endpoints (start/list/get/stop), plus LiteLLM_ShadowEvalJob / LiteLLM_ShadowEvalVerdict tables with a partial unique index enforcing one active job per key. Start returns an upfront judge-cost estimate from trailing daily spend volume; jobs auto-stop at ends_at or a 1.5x spend cap.

New ShadowEvalLogger runs off the success hook (one dict lookup when idle): deterministic sampling, concurrency shedding, budget/redaction/self-shadow skips, and a 10s lifecycle loop for counter flush and finalization. Shadow/judge calls bill to the shadowed key but are stamped internal_call_origin, so spend writers and autorouter session rollups keep spend while zeroing api_requests and autorouter savings.

Also extracts shared llm_judge and internal_call_metadata helpers; the llm-as-a-judge guardrail and complexity router now delegate to them instead of private copies.

Reviewed by Cursor Bugbot for commit 52ad253. Bugbot is set up for automated code reviews on this repo. Configure here.

@tin-berri
tin-berri requested a review from a team August 11, 2026 20:26
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds backend shadow evaluations for auto-router adoption, including durable jobs and verdicts, sampling and judging pipelines, management endpoints, and internal-call accounting exclusions

  • Adds shadow-evaluation job lifecycle, deterministic sampling, router duplication, blind judging, and aggregated results
  • Adds database models and migration constraints for jobs and verdicts
  • Refactors shared judge dispatch and internal-call metadata handling
  • Excludes classifier, shadow, and judge sub-calls from request-volume and auto-router adoption metrics while retaining their spend

Confidence Score: 1/5

This PR is not safe to merge until budget admission, post-stop verdict consistency, and referenced-key validation are corrected

Concurrent shadow calls can exceed key or team budgets, completed jobs can acquire uncounted verdicts, and start can create evaluations that no legitimate traffic can ever reach

Files Needing Attention: litellm/integrations/shadow_eval_logger.py, litellm/proxy/management_endpoints/auto_router_endpoints.py

Security Review

The shadow pipeline forwards key and team identity into chargeable internal calls but only performs a non-atomic spend read, allowing concurrent pipelines to exceed configured budgets. How this was verified: The direct Router/SDK calls omit reservation metadata and increment the same spend counters only after completion

Important Files Changed

Filename Overview
litellm/integrations/shadow_eval_logger.py Implements the shadow pipeline and lifecycle, but permits post-completion verdict writes and non-atomically admits chargeable calls near budget limits
litellm/proxy/management_endpoints/auto_router_endpoints.py Adds start, list, get, and stop APIs plus aggregation and estimates, but start accepts nonexistent or noncanonical key identifiers
litellm/litellm_core_utils/internal_call_metadata.py Centralizes identity forwarding and reservation stripping for internal calls; its behavior exposes the shadow pipeline's missing independent reservation
litellm/litellm_core_utils/llm_judge.py Consolidates verdict parsing, model resolution, and Router-or-SDK judge dispatch without an identified regression
litellm/proxy/db/db_spend_update_writer.py Preserves internal-call spend and token accounting while excluding those calls from user request and auto-router savings metrics
litellm/proxy/db/autorouter_session_rollup.py Excludes internal routed sub-calls from adoption session rollups
litellm/proxy/proxy_server.py Registers one shadow logger and starts its lifecycle loop with duplicate-registration protection
schema.prisma Adds shadow-evaluation job and verdict models consistently with the two schema copies, though verdict writes are not relationally constrained by job lifecycle
litellm-proxy-extras/litellm_proxy_extras/migrations/20260811125249_add_shadow_eval/migration.sql Creates the new tables and enforces one pending or running job per key through a partial unique index
litellm/proxy/guardrails/guardrail_hooks/llm_as_a_judge/init.py Delegates existing judge parsing and dispatch behavior to the new shared utilities
litellm/router_strategy/complexity_router/complexity_router.py Replaces private internal-call metadata sanitization with the shared implementation
tests/test_litellm/integrations/test_shadow_eval_logger.py Covers sampling, pipeline outcomes, budgets, metadata, and lifecycle basics but not the completion-versus-in-flight-verdict race
tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py Covers endpoint authorization and common validation but not invalid, raw, deleted, or nonexistent key identifiers

Reviews (1): Last reviewed commit: "feat: pre-adoption shadow eval for the a..." | Re-trigger Greptile

Comment on lines +492 to +502
await prisma.db.litellm_shadowevalverdict.create(
data={ # mutable-ok: Prisma payload
"job_id": job.id,
"request_id": request_id,
"tier_classification": shadow.tier,
"real_model": real_model,
"shadow_model": shadow.model,
"judge_preference": verdict.preference,
"judge_confidence": verdict.confidence,
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Post-stop verdicts mutate results

When completion overlaps an in-flight pipeline, the unconditional verdict insert succeeds while counters remain frozen, so completed-job results change and disagree with counts

Knowledge Base Used: Database Schema and Proxy DB Access Layer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d3376e: the status guarded counter update now decides whether the verdict lands, pinned by test_job_stopped_mid_flight_drops_the_verdict

Comment on lines +474 to +477
if await _key_or_team_is_over_budget(budget_metadata):
return

shadow: Final = await self._call_router_shadow(job.router_name, messages, model_parameters, parent_metadata)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Budget admission is non-atomic

If shadow pipelines overlap near a budget limit, each passes the same read and collectively overspends the key or team

How this was verified: Internal calls omit reservations and update spend only after completion

Knowledge Base Used: Cost Tracking and Budget Enforcement

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As designed and documented: overshoot is bounded by the 16 per pod cap, spend lands on the same counters, and the job spend cap bounds the eval

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — the overshoot is explicitly bounded and documented, which addresses the concern. The 16-per-pod cap sets a hard ceiling on how many pipelines can race past the budget read simultaneously, the job spend cap provides a secondary bound on the overall eval, and the PR description calls this out explicitly as an intentional trade-off against double-finalization risk. The soft check is appropriate here.

Comment on lines +705 to +717
existing: Final = await prisma_client.db.litellm_shadowevaljob.find_first(
where={ # mutable-ok: Prisma filter
"api_key_id": data.api_key_id,
"status": {"in": ["pending", "running"]}, # mutable-ok: Prisma filter
},
)
if existing is not None:
raise HTTPException(
status_code=409,
detail=f"Key already has an active shadow eval job ({existing.id}). Stop it first.",
)

recent_requests: Final = await _recent_request_volume(prisma_client, data.api_key_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Invalid keys create idle jobs

When an admin submits a noncanonical or nonexistent key identifier, start persists it successfully, creating an evaluation that legitimate traffic never matches

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d3376e: start now 400s when api_key_id is not a LiteLLM_VerificationToken row, with a pinning test

real_text: Final = self._extract_response_text(response_obj)
if not real_text or not messages:
return
if await _key_or_team_is_over_budget(budget_metadata):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Shadow calls bypass budget reservations

Once an eval is active, a key holder can submit concurrent requests whose shadow and judge tasks all pass this read-only check before their spend is recorded, exceeding key or team quotas; user, organization, project, end-user, and model budgets are not checked here at all. Reserve the estimated cost of both internal calls atomically against the same budget counters before dispatch and reconcile it with their actual combined cost afterward; the job's cost_actual and spend cap should also include the shadow-router call rather than only verdict.cost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined as designed: the soft check plus in flight cap and per job spend cap bound overshoot; reservations for detached sub calls risk double finalization

Comment thread litellm/integrations/shadow_eval_logger.py
Comment thread litellm/integrations/shadow_eval_logger.py Outdated
@veria-ai

veria-ai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR adds backend support for pre-adoption shadow evaluation of the auto-router, including shadow-router and judge calls and tracking their evaluation costs.

One budget-enforcement issue remains open after another issue was addressed. An authorized key holder can submit concurrent evaluation requests whose internal shadow and judge calls bypass atomic budget reservations and several quota checks, potentially causing spend to exceed configured key, team, user, organization, project, end-user, or model limits.

Open issues (1)

Fixed/addressed: 1 · PR risk: 6/10

@tin-berri
tin-berri force-pushed the litellm_shadoweval_backend branch from d1420d5 to 8d3376e Compare August 11, 2026 20:39
@tin-berri

Copy link
Copy Markdown
Contributor Author

Addressed the review in 8d3376e: post-stop verdicts now impossible (guarded counter decides the insert), start validates the key exists, shadow and judge calls never retry or fall back. Budget stays a soft check by design; bounds are in the PR body. @greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 48 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/integrations/shadow_eval_logger.py 87.91% 36 Missing ⚠️
...roxy/management_endpoints/auto_router_endpoints.py 91.24% 12 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

The three items you addressed are verified in the code:

  • Post-stop verdict drop — the update_many in _run_shadow_eval with "status": {"in": ["pending", "running"]} is the canonical gate: counted = 0 → verdict row is never created. test_job_stopped_mid_flight_drops_the_verdict pins it correctly.
  • Key existencefind_unique on litellm_verificationtoken before the job is created; clear 400 on miss.
  • No retries/fallbacksnum_retries=0, fallbacks=[] on both the shadow router call and the router-dispatch path in judge_acompletion. Confirmed in test_sub_calls_carry_identity_and_origin_but_never_parent_request_state.
  • Lifecycle loop startupcost_tracking() registers the logger and calls start_lifecycle_loop() on the registered instance; idempotency and loop identity are both pinned in test_lifecycle.py.

Two minor points that hold back a 5:

1. SDK judge path does not pass fallbacks=[]

In judge_acompletion, the router branch passes num_retries=0, fallbacks=[]. The SDK branch (litellm.acompletion) only passes num_retries=0. litellm.acompletion does accept fallbacks as a kwarg. If a global litellm.fallbacks is configured on the proxy, a failed SDK-dispatched judge call could silently multiply spend through a fallback rather than counting as a single miss:

# llm_judge.py – SDK branch
return await litellm.acompletion(model=judge_model, messages=messages, num_retries=0, **params)
#                                                                                  ^ fallbacks=[] missing

2. stop_shadow_eval_job update is not status-guarded

The read-then-update in stop_shadow_eval_job checks record.status but the subsequent db.update has no status guard. If the lifecycle loop finalizes the job in the ~one-tick window between the endpoint's find_unique and its update, the update succeeds and overwrites completed_at with a new timestamp. Not a correctness bug, but inconsistent with every other write in the feature.

The live-provider staging pass the PR notes is the remaining gate before merge. Everything else looks production-ready.

Comment thread litellm/integrations/shadow_eval_logger.py
@tin-berri

Copy link
Copy Markdown
Contributor Author

Bugbot round two addressed in 43a67bb: the recording section is now one transaction, so a pipeline lands in exactly one of completed, failed, or dropped and counts always match stored verdicts. @greptileai can you reply here with a score and why that score?

@tin-berri
tin-berri force-pushed the litellm_shadoweval_backend branch from 8d3376e to 43a67bb Compare August 11, 2026 21:15
@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Score: 3.5 / 5

The transaction fix is correct and closes the specific atomicity gap. Here is the full breakdown.


What the fix gets right

The three-way invariant now holds in production:

async with prisma.tx() as transaction:
    counted: Final = await transaction.litellm_shadowevaljob.update_many(
        where={"id": job.id, "status": {"in": ["pending", "running"]}},
        ...
    )
    if counted:
        await transaction.litellm_shadowevalverdict.create(...)
  • Stopped mid-flight: update_many matches 0 rows → counted == 0 → verdict skipped. Transaction commits, nothing written.
  • Failed verdict write: create raises → transaction rolls back, counter increment is undone → outer except fires _bump_failed against prisma.db (outside the now-rolled-back transaction). One failure, no phantom completed count.
  • Happy path: both writes land or neither does.

The if counted: truthiness check works because litellm/repositories/prisma_protocols.py:22 types update_many as -> int (Python's prisma-client-py returns a plain int, not a BatchPayload object), which matches the integer mocks in the test (return_value=0 / return_value=1). test_job_stopped_mid_flight_drops_the_verdict and test_failed_verdict_write_files_the_pipeline_under_failed_once both verify the right invariants.


What is still holding back a 4/5

  1. Real-provider smoke test explicitly owed. The entire demo in the PR description was against a stubbed upstream (all local credentials were dead). Spend attribution, internal_call_origin exclusion from api_requests, and the autorouter_session_rows = 0 claim are all unverified against a real provider. The author flags this themselves, and two prior precedent PRs merged without it — but for a feature that touches spend accounting and metric exclusion on the production path, a staging run with live providers before merge is the right bar.

  2. call_type gate allows None through (shadow_eval_logger.py, the guard in async_log_success_event):

    if payload.get("call_type") not in (None, "completion", "acompletion", "chat_completion"):
        return

    None is explicitly kept in the pass-through set. If call_type is absent on any request that somehow reaches the success hook, it will be sampled rather than skipped. The description says embeddings and /v1/responses traffic are excluded "by the call_type gate by design" — but the gate only excludes known non-chat types, not unknown/missing ones. Removing None from the allowlist (not in ("completion", "acompletion", "chat_completion")) would be safer and still let all valid chat payloads through, since those always carry a call_type.

Neither of these is a regression against existing functionality, but together they mean the feature hasn't been exercised against a real provider and there is a latent miss in the call_type gate. Fix point 2, land the real-provider run, and this is a clean 4/5.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 43a67bb. Configure here.

@codspeed-hq

codspeed-hq Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_shadoweval_backend (4a96d07) with litellm_internal_staging (40cd969)

Open in CodSpeed

@tin-berri
tin-berri force-pushed the litellm_shadoweval_backend branch from 43a67bb to df0eb9d Compare August 11, 2026 22:09
@tin-berri

Copy link
Copy Markdown
Contributor Author

Fixed the call_type gate in df0eb9d: unknown or missing types now fail closed, test updated. The real provider staging run stays owed before merge. @greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Score: 3.5 / 5

Here's the breakdown:

What's now solid (the call_type fix is correct)

The gate at shadow_eval_logger.py:262:

if payload.get("call_type") not in ("completion", "acompletion", "chat_completion"):
    return

does fail closed for None: None not in ("completion", "acompletion", "chat_completion") is True, so missing types skip. The parametrized test with id="missing-call-type" confirms it. This was the right fix.

Why 3.5 and not higher

  1. Staging run still owed. This is the main thing holding the score. The live proof ran every provider call through a stub; the actual shadow-call + judge pipeline path has not been validated end-to-end with real credentials and real token spend. Given that the whole point of this feature is spend attribution correctness, a staging pass is not optional polish — it's validation of the core invariant the PR claims to uphold (shadow/judge spend lands on the right key, doesn't inflate api_requests, doesn't touch LiteLLM_AutoRouterSession). Until that run is done, the proof in the PR description is incomplete.

  2. chat_completion in the allowlist is dead weight. LiteLLM's call_type in StandardLoggingPayload takes values from CallTypes; chat_completion is not one of them. It's not harmful (the gate is still fail-closed), but it's noise that could mislead a future reader about what's actually valid.

  3. _inflight_shadow_tasks counter manipulation. The done-callback uses setattr(self, "_inflight_shadow_tasks", self._inflight_shadow_tasks - 1). This is fine under asyncio's single-threaded event loop, but it's fragile — if the callback ever ends up called from a thread (e.g. via loop.call_soon_threadsafe), the read-modify-write races. A plain self._inflight_shadow_tasks -= 1 in the callback body is both cleaner and has the same semantics.

What earns the 3.5 and not lower

The architecture is genuinely well-designed: one dict lookup in the hot path, all lifecycle off-path, status-guarded transaction for verdict+counter atomicity, spend cap with floor, sanitized_forwardable_call_metadata correctly stripping budget reservations to prevent the double-finalization bug, deterministic hash sampling for retry-idempotency. The test suite covers the skip paths, pipeline failure modes, and lifecycle thoroughly.

To get to 4/5: complete the live-provider staging run and post a brief summary of the spend attribution rows from a real Postgres instance (same format as the PR description, but with real provider responses, not stubs). The chat_completion cleanup is a minor nice-to-have alongside that.

@tin-berri
tin-berri force-pushed the litellm_shadoweval_backend branch from df0eb9d to 4a96d07 Compare August 11, 2026 22:14
@tin-berri

Copy link
Copy Markdown
Contributor Author

Both minor points fixed in 4a96d07: chat_completion dropped from the allowlist and the slot release is a bound method. Staging run remains the open item. @greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

4/5 — the code is in very good shape. Here's the reasoning:

What earns the score:

  • Both fixes from the thread land cleanly: chat_completion gone from the call-type allowlist (only completion/acompletion pass), and _release_shadow_slot is a bound method so the decrement always fires even if the task raises
  • The single 24k total character budget (not three independent 16k caps) is correct and tested in test_judge_prompt_is_bounded_however_large_the_inputs
  • The hook is genuinely O(1) for idle keys — one dict lookup, nothing else
  • Transaction atomicity is right: counter increment and verdict create ride the same prisma.tx(), so a failed write cannot produce a counted-but-missing verdict, and the status guard means a job stopped mid-flight stores nothing
  • Budget reservation stripping in internal_call_metadata.py prevents the double-finalization bug explicitly documented in the module docstring
  • The partial unique index in the migration is the correct enforcement point for one-active-job-per-key; the advisory read-then-create above it handles the common case, and _is_unique_violation surfaces the race as the same 409
  • Test coverage is solid: 8 parametrized skip paths, pipeline failure modes (provider explodes, unparseable verdict, failed verdict write, job stopped mid-flight), lifecycle tick (expire, overspend, active surviving), sampling distribution, idempotent loop start

What holds it at 4 instead of 5:

The staging run with live providers is the one genuine open item. This is a feature that bills real provider spend to keys through a background path that bypasses the normal auth+budget reservation lifecycle. The live proof used stubbed upstreams (all local credentials dead), so real-provider behavior — cost attribution landing in LiteLLM_SpendLogs, api_requests staying at zero for shadow/judge rows, the spend cap halting the job at the right threshold — has not been observed end-to-end. That's the one thing that needs to close before merge.

Everything else is addressed. Once a staging pass with a live key and real providers confirms the spend attribution and metric exclusion paths behave as the Postgres output shows for the stub run, this is ready.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 4a96d07. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

4/5 — the reuse pass delivered real improvement and the architecture is solid; one concrete bug remains before merge.


What the reuse pass got right

  • _key_or_team_is_over_budget importing and calling _virtual_key_max_budget_check / _team_max_budget_check / get_team_object from auth_checks is the correct fix. Counter keys and thresholds now literally cannot drift from the auth path because they are the auth path.
  • GetShadowEvalJobResponse with from_attributes=True + AliasChoices("id", "job_id") is clean. _job_to_response is three lines and no hand-written mapping.
  • _require_admin_viewer / _require_admin_writer extracted and the benchmarks endpoint retroactively using it is right.
  • Metric exclusion in db_spend_update_writer (api_requests=0 if is_internal_call else 1) and the session rollup gate are both correct and well-commented.
  • Registration with the isinstance guard (_register_shadow_eval_logger) is the right pattern — idempotent, loop and instance can't be reordered apart.

The one remaining bug (breaks a stated invariant)

In _run_shadow_eval, the transaction block:

counted: Final = await transaction.litellm_shadowevaljob.update_many(...)
if counted:
    await transaction.litellm_shadowevalverdict.create(...)

update_many in prisma-client-py returns a BatchPayload object (e.g. {"count": 0}), which is always truthy as a non-None object regardless of how many rows matched. When the job has already been stopped, update_many touches 0 rows — but if counted: still passes, so the verdict is written anyway. The PR description explicitly promises "a pipeline finishing after its job stopped stores nothing" — this breaks that invariant.

Fix: if counted.count: instead of if counted:.

Fix that and this is a clean 5/5.

Comment thread litellm/integrations/shadow_eval_logger.py
@tin-berri

Copy link
Copy Markdown
Contributor Author

Declined: the Python prisma client returns int from update_many, not BatchPayload; prisma_protocols.py:26 types it, and a live tx probe returned falsy 0 on zero matches

@tin-berri

Copy link
Copy Markdown
Contributor Author

Probe for the record: inside db.tx(), update_many on a no-match filter returned 0 as int with bool False; counted.count would AttributeError on every pipeline

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Score: 3/5

The architecture is genuinely well-thought-out — one dict lookup on idle paths, deterministic hash sampling, internal_call_metadata correctly strips budget reservations to prevent double-finalization, the lifecycle loop is off the request path, and the partial unique index for one-active-job-per-key is the right approach. Here's why it doesn't score higher:


Bug: if counted: is always True

The most significant issue is in _run_shadow_eval in shadow_eval_logger.py:

counted: Final = await transaction.litellm_shadowevaljob.update_many(
    where={"id": job.id, "status": {"in": ["pending", "running"]}},
    data={...},
)
if counted:  # ← always True — BatchPayload is an object, not an int
    await transaction.litellm_shadowevalverdict.create(...)

update_many returns a Prisma BatchPayload (a Pydantic BaseModel with a count: int field). Pydantic models don't override __bool__, so bool(BatchPayload(count=0)) is True. A pipeline finishing after its job was stopped will still write a verdict row, even though completed_count wasn't incremented — directly violating the PR's stated correctness guarantee that "a job's counts always match its stored verdicts." The fix is if counted.count:.


Other concerns:

  • No test files confirmed: GitHub code search for shadow_eval in tests/ returns zero results and no shadow_eval test file appears in the directory listing. The checklist marks this as done, but it can't be confirmed. The feature touches spend attribution, a correctness-sensitive path.

  • Live upstream was explicitly skipped: The PR states "all local provider credentials are dead; a staging pass with a live key and real providers is owed before merge." End-to-end behavior with real providers is unvalidated.

  • judge_acompletion SDK path missing fallbacks=[]: The router path passes fallbacks=[] but the SDK path (non-router judge) doesn't, inconsistent with the stated design goal of "retries and fallbacks disabled so a failed call is a counted miss rather than a spend multiplier."

  • Lifecycle loop start not confirmed: start_lifecycle_loop() is not called in __init__ and ShadowEvalLogger is not in CustomLoggerRegistry. Where it's wired into proxy startup couldn't be confirmed. If it's not reliably started, jobs never finalize and counters never flush.

@tin-berri
tin-berri force-pushed the litellm_shadoweval_backend branch from 9b4fd37 to 781de79 Compare August 11, 2026 23:49
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Score: 3.5 / 5

This is a genuinely well-thought-out feature with solid architectural decisions, but several issues prevent a higher score.


Why it is not higher

1. Real-provider validation deferred (PR's own admission)
Every provider credential in the test rig is dead; a staging pass is owed before merge. For a feature whose core value is comparing response quality, stub responses give very little confidence that the judge prompt, verdict parser, and tier attribution work end-to-end against real models.

2. _key_or_team_is_over_budget silently skips on ImportError during proxy startup

try:
    from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
except ImportError:
    return False

On a pod where the proxy has not finished initializing, the financial safety gate is silently skipped — not just once at startup, but on every call until the import succeeds. "Fail-open" is documented, but the scope of that failure is wider than the docstring implies.

3. Multi-pod spend-cap overshoot is understated
The PR says overshoot is bounded by "16 in-flight pipelines × one pipeline's cost". But cost_actual is read from a snapshot refreshed every 10s, and the 16-cap is per-pod, not cluster-wide. On a multi-pod deployment the real bound is (16 × pod_count) × pipeline_cost × up_to_one_tick_lag. Probably acceptable in practice, but the description should state this explicitly.

4. No test for the stopped-job verdict-drop invariant
The most important correctness property in the design is: "a pipeline finishing after its job was stopped stores nothing." There is no test that starts a job, stops it, lets an in-flight pipeline complete, and asserts the verdict was silently dropped. Without this, a regression in the Prisma transaction guard (status: {in: ["pending","running"]}) is invisible.

5. _sample_hits determinism depends on payload ID stability across retries
sha256(job_id + ":" + request_id) is correct across pods, but if the gateway assigns a new StandardLoggingPayload ID to a retried call, the retry lands in a different bucket than the original. Low severity, but worth confirming the ID source is stable across retries.


Why it is not lower

  • Zero-latency shadow path, identity forwarding, budget-reservation sanitization, no recursion on own traffic, and metric exclusion for internal sub-calls are all implemented correctly.
  • Shared llm_judge.py and internal_call_metadata.py replace private copies — a genuine maintainability win.
  • Lifecycle loop started by registration (not by the first request) is the right design.
  • Partial unique index for one-active-job-per-key and job_id index on verdicts correctly match the read patterns at scale.
  • The spend-log and api_requests exclusion for shadow/judge sub-calls is verified in the live proof.

Bottom line: The architecture is sound and the tradeoffs are well-reasoned, but the PR needs (a) a real-provider staging run, (b) a test for the stopped-job verdict-drop invariant, and (c) an honest statement of multi-pod overshoot bounds before it can be merged with confidence.

@tin-berri

Copy link
Copy Markdown
Contributor Author

On BatchPayload: the generated Python client types update_many as int, and a live db.tx() probe returned falsy 0 on zero matches

@tin-berri

Copy link
Copy Markdown
Contributor Author

Tests exist in this diff: tests/test_litellm/integrations/test_shadow_eval_logger.py, 36 tests; your earlier review cited test_job_stopped_mid_flight_drops_the_verdict by name

@tin-berri

Copy link
Copy Markdown
Contributor Author

Lifecycle start is wired in cost_tracking via _register_shadow_eval_logger, pinned by test_registration_starts_the_lifecycle_loop_on_the_registered_instance

@tin-berri

Copy link
Copy Markdown
Contributor Author

fallbacks=[] on the SDK path would be a no-op: main.py resolves fallbacks or litellm.model_fallbacks, and num_retries=0 already prevents retry spend

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@tin-berri

Copy link
Copy Markdown
Contributor Author

The stopped-job invariant test exists: test_job_stopped_mid_flight_drops_the_verdict, cited by your own earlier review; body now states multi-pod overshoot bounds

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

The implementation is architecturally sound and shows careful thinking on the hard problems. Here's the breakdown:


What earns the points (4):

  • Spend isolation is correct. The is_internal_call gate in db_spend_update_writer.py zeros api_requests, successful_requests, failed_requests, and autorouter_savings_spend for shadow/judge calls, while keeping spend and tokens — exactly the right semantics. The autorouter_session_rollup.py exclusion by INTERNAL_CALL_ORIGIN_METADATA_KEY is equally correct.
  • Race conditions are handled properly. The partial unique index in the migration enforces one-active-job-per-key at the DB level. Status-guarded update_many in _flush_seen_counts, _finalize_job, and _run_shadow_eval's transaction means a pipeline finishing after a stop stores nothing, and counts always match stored verdicts.
  • Deterministic sampling via SHA-256 hash of job_id:request_id means pods agree without coordination and retries sample identically.
  • Lifecycle loop is clean. Registration owns the loop start behind an isinstance guard; finalization runs off-request path so a job ends on schedule even with no traffic.
  • internal_call_metadata.py is a clean extraction. The budget-reservation sanitization is well-documented and the distinction between forwarded_internal_call_metadata (in-request) and sanitized_forwardable_call_metadata (detached, identity-only) is the right separation.
  • Budget cap is sensibly designed. The soft pre-dispatch check is documented; overshoot is bounded by _MAX_CONCURRENT_SHADOW_TASKS × one pipeline's cost; the per-job cap applies regardless.
  • Tests are included for the logger, the endpoints, the judge utilities, the metadata helper, and the spend writer.

What keeps it from 5:

  1. Upstream was stubbed. This is the main blocker. All provider credentials were dead during the live proof; every shadow call, judge call, and tier classification came from a stub. The code paths that actually matter — real provider latency under the 16-task cap, real judge output triggering the verdict parser, real spend landing on the daily rollup — have not been exercised end-to-end. The PR explicitly acknowledges this is owed before merge.

  2. stop_shadow_eval_job update has no status filter. The read checks record.status not in ("pending", "running"), but the subsequent update uses only where={"id": job_id} with no status guard. If the lifecycle loop completes the job between the read and the write, the update overwrites completed_at with a new timestamp. Harmless in practice, but inconsistent with the status-guarded pattern used everywhere else.

  3. turn_off_message_logging=None passed explicitly to the router. When the parent call did not set this flag, turn_off_message_logging is None and is still forwarded as an explicit kwarg. Depending on how the router and downstream SDK handle an explicit None vs. a missing kwarg this could behave unexpectedly. The classifier uses the same propagation path so the risk is symmetric, but it's worth a look.


Summary: The design decisions (spend isolation, sampling, lifecycle, race handling) are all correct and well-executed. The score gap is almost entirely the stubbed-upstream gap — a staging pass with real providers should close it before merge.

Comment thread litellm/integrations/shadow_eval_logger.py
Comment thread litellm/litellm_core_utils/llm_judge.py
@tin-berri
tin-berri force-pushed the litellm_shadoweval_backend branch from 781de79 to 52ad253 Compare August 12, 2026 00:08
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Score: 3.5 / 5

Here's my breakdown:


What's strong (pushes toward 4–5)

  • The core architecture is well thought out. The hot path is genuinely cheap: one dict lookup in async_log_success_event, lifecycle management completely off the request path.
  • The deterministic hash sampling (_sample_hits) is correct — retries sample the same way and pods agree without coordination.
  • The transaction wrapping the update_many + verdict.create is the right fix for counter/verdict divergence. The status guard on the counter update means a pipeline finishing after stop stores nothing — this was broken in the first iteration and is now correct.
  • internal_call_metadata.py is a clean shared abstraction. Stripping user_api_key_budget_reservation before forwarding and copying only identity metadata for detached sub-calls is exactly right.
  • Spend attribution and metric exclusion are clean — internal_call_origin stamps correctly exclude shadow/judge spend from api_requests and the autorouter session rollup.
  • The should_redact_message_logging gate (skipping the pipeline entirely for redacted traffic) is stronger than the logging propagation approach and is the right call.
  • The key-existence validation on start (400 on unknown api_key_id) was fixed and tested.

What holds it back (pushes toward 3)

  1. SDK judge fallbacks thread is unresolved. The open Bugbot thread on llm_judge.py#L84 flagged that litellm.acompletion on the SDK path only sets num_retries=0 but not fallbacks=[]. The author's response — "fallbacks=[] would be falsy and ignored there anyway" — is a weak defense. If litellm.model_fallbacks is set globally, a per-call fallbacks=[] kwarg is the correct override, and omitting it means a failed judge call can cascade into billed fallback calls rather than a counted miss. This needs a clear code fix or a citation that the SDK's per-call fallbacks kwarg cannot override the global setting.

  2. Real-provider validation is explicitly deferred. The PR acknowledges: "a staging pass with a live key and real providers is owed before merge." This is the right honesty, but it means the end-to-end spend attribution, internal_call_origin stamping in real LiteLLM_SpendLogs rows, and budget counter behavior have only been validated against stubs.

  3. call_type gate fails closed but may be confusing. The hook skips anything not "completion" or "acompletion". /v1/messages and /v1/responses are explicitly called out as out of scope, but the gate also silently skips any future call types. A comment stating this is intentional would help.


Summary

The implementation is serious and iterates correctly on findings — major issues (transaction atomicity, post-stop verdicts, retry/fallback on shadow, key validation, redaction) were all addressed. The one remaining open thread (SDK fallback suppression) is a concrete gap on a code path that's billed to customer keys, and the absence of real-provider staging validation is flagged by the author. Fix those two before requesting maintainer merge.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 52ad253. Configure here.

)
task.add_done_callback(self._release_shadow_slot)
except Exception as e: # noqa: BLE001 # logging hooks must never fail the request
verbose_logger.debug("shadow_eval: failed to schedule task: %s", e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inflight slot leaked on schedule failure

Medium Severity

_inflight_shadow_tasks is incremented before asyncio.create_task and add_done_callback, but the surrounding except never decrements it. If scheduling fails after the increment (argument evaluation, create_task during shutdown, etc.), that slot is never released. After 16 such failures on a pod, the inflight cap stays saturated and shadow sampling stops until process restart.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 52ad253. Configure here.

data={ # mutable-ok: Prisma payload
"failed_count": {"increment": 1}, # mutable-ok: Prisma operator
"last_error": error[:_MAX_LAST_ERROR_CHARS],
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed jobs stay pending while spending

Medium Severity

status is flipped to running only inside the successful verdict transaction. _bump_failed increments failed_count and last_error but leaves status at pending. A job that is actively sampling and billing shadow/judge calls while every pipeline fails therefore keeps reporting pending, which reads as not started even though spend and failures are accumulating.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 52ad253. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

Superseded by #36587 (rebuilt shadow-eval logger).

@tin-berri tin-berri closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant