feat: time-bound shadow eval jobs with a real start form

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>
This commit is contained in:
Abhimanyu Kapur 2026-08-08 12:36:34 -07:00
parent 8485a0d859
commit 9f9ae2b718
13 changed files with 503 additions and 72 deletions

View file

@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS "LiteLLM_ShadowEvalJob" (
"cost_actual" DOUBLE PRECISION NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
"ends_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
CONSTRAINT "LiteLLM_ShadowEvalJob_pkey" PRIMARY KEY ("id")

View file

@ -1473,6 +1473,7 @@ model LiteLLM_ShadowEvalJob {
created_at DateTime @default(now())
created_by String? // user_id of the key that created this job
ends_at DateTime?
completed_at DateTime?
@@index([team_id, status])

View file

@ -19,8 +19,9 @@ Shadow and judge calls carry ``shadow_eval_internal`` metadata so this logger
ignores its own traffic and cannot recurse. They also carry the shadowed key's
identity metadata, so the provider spend they incur is attributed to that key
(and its team/org/user) and counts against every budget that key is subject to,
including the global proxy budget. A job additionally stops itself once its judge
spend reaches a multiple of the estimate quoted when it started.
including the global proxy budget. A job additionally stops itself once its
sampling window (``ends_at``) closes or its judge spend reaches a multiple of
the estimate quoted when it started.
"""
import asyncio
@ -173,6 +174,24 @@ class ActiveShadowEvalJob:
status: str
cost_estimate: float | None = None
cost_actual: float = 0.0
ends_at: datetime | None = None
def _as_utc(value: object) -> datetime | None:
if not isinstance(value, datetime):
return None
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
def _job_is_past_its_end(job: ActiveShadowEvalJob) -> bool:
"""Whether the job's sampling window has closed.
An eval is a measurement, not a service: it prices a fixed window at start
time, so sampling past ends_at would bill traffic the estimate never covered.
Jobs created before durations existed have no ends_at and only stop by hand
or via the spend cap.
"""
return job.ends_at is not None and datetime.now(timezone.utc) >= job.ends_at
def _job_is_over_spend_cap(job: ActiveShadowEvalJob) -> bool:
@ -238,8 +257,14 @@ class ShadowEvalLogger(CustomLogger):
job: Final = await self._get_active_job(api_key_hash)
if job is None:
return
if _job_is_past_its_end(job):
await self._finalize_job(job, "reached its scheduled end")
return
if _job_is_over_spend_cap(job):
await self._stop_job_over_spend_cap(job)
await self._finalize_job(
job,
f"spend ${job.cost_actual:.4f} reached the cap for its ${job.cost_estimate or 0.0:.4f} estimate",
)
return
request_id: Final = payload.get("id") or ""
if not request_id:
@ -305,6 +330,7 @@ class ShadowEvalLogger(CustomLogger):
status=str(record.status),
cost_estimate=float(record.cost_estimate) if record.cost_estimate is not None else None,
cost_actual=float(record.cost_actual or 0.0),
ends_at=_as_utc(getattr(record, "ends_at", None)),
)
if record is not None
else None
@ -315,11 +341,11 @@ class ShadowEvalLogger(CustomLogger):
self._job_cache[api_key_hash] = (now, job)
return job
async def _stop_job_over_spend_cap(self, job: ActiveShadowEvalJob) -> None:
"""Flip a runaway job to completed, keeping the verdicts it already produced.
async def _finalize_job(self, job: ActiveShadowEvalJob, reason: str) -> None:
"""Flip a finished job to completed, keeping the verdicts it already produced.
Guarded on the job still being pending/running so two pods breaching the cap on
the same job cannot resurrect one an admin stopped in between.
Guarded on the job still being pending/running so two pods finishing the
same job cannot resurrect one an admin stopped in between.
"""
prisma: Final = self._prisma_provider()
if prisma is None:
@ -327,12 +353,7 @@ class ShadowEvalLogger(CustomLogger):
self._job_cache = { # mutable-ok: TTL cache
k: v for k, v in self._job_cache.items() if v[1] is None or v[1].id != job.id
}
verbose_logger.info(
"shadow_eval: stopping job %s, spend $%.4f reached the cap for its $%.4f estimate",
job.id,
job.cost_actual,
job.cost_estimate or 0.0,
)
verbose_logger.info("shadow_eval: stopping job %s: %s", job.id, reason)
try:
await prisma.db.litellm_shadowevaljob.update_many(
where={ # mutable-ok: Prisma filter
@ -345,7 +366,7 @@ class ShadowEvalLogger(CustomLogger):
},
)
except Exception as e: # noqa: BLE001 # logging hooks must never fail the request
verbose_logger.debug("shadow_eval: failed to stop over-cap job %s: %s", job.id, e)
verbose_logger.debug("shadow_eval: failed to stop job %s: %s", job.id, e)
async def _flush_seen_counts(self) -> None:
prisma: Final = self._prisma_provider()

View file

@ -607,6 +607,7 @@ class _ShadowEvalJobRow(BaseModel):
cost_estimate: float | None = None
cost_actual: float = 0.0
created_at: datetime
ends_at: datetime | None = None
completed_at: datetime | None = None
@ -629,6 +630,7 @@ def _job_to_response(record: object, results: ShadowEvalResult | None) -> GetSha
cost_estimate=row.cost_estimate,
cost_actual=row.cost_actual,
created_at=row.created_at.isoformat(),
ends_at=row.ends_at.isoformat() if row.ends_at else None,
completed_at=row.completed_at.isoformat() if row.completed_at else None,
)
@ -649,10 +651,11 @@ async def start_shadow_eval(
traffic through an auto-router, judge real vs. shadow responses blind, and
stratify win rates by the router's tier classification.
The shadow responses are never served to users. The job stays active until
stopped via /auto_router/shadow_eval/{job_id}/stop. Judge calls bill to the
proxy; the estimate returned here prices them from the key's trailing
request volume.
The shadow responses are never served to users. The job samples traffic for
duration_days (or until stopped via /auto_router/shadow_eval/{job_id}/stop),
then completes itself. Judge calls bill to the shadowed key; the estimate
returned here prices them from the key's trailing request volume scaled to
the requested duration.
"""
from litellm.proxy.proxy_server import llm_router, prisma_client
@ -684,9 +687,12 @@ async def start_shadow_eval(
"startTime": {"gte": lookback_start}, # mutable-ok: Prisma filter
},
)
weekly_sampled: Final = int(recent_requests * data.shadow_percentage / 100.0)
sampled: Final = int(
recent_requests * (data.duration_days / _ESTIMATE_LOOKBACK_DAYS) * data.shadow_percentage / 100.0
)
per_call: Final = _estimate_judge_cost_per_call(data.judge_model)
estimated_cost: Final = round(weekly_sampled * per_call, 2)
estimated_cost: Final = round(sampled * per_call, 2)
ends_at: Final = datetime.now(timezone.utc) + timedelta(days=data.duration_days)
job: Final = await prisma_client.db.litellm_shadowevaljob.create(
data={ # mutable-ok: Prisma payload
@ -698,12 +704,13 @@ async def start_shadow_eval(
"status": "pending",
"cost_estimate": estimated_cost,
"created_by": user_api_key_dict.user_id,
"ends_at": ends_at,
}
)
return StartShadowEvalResponse(
job_id=job.id,
status="pending",
estimated_request_count=weekly_sampled,
estimated_request_count=sampled,
estimated_cost=estimated_cost,
)

View file

@ -1473,6 +1473,7 @@ model LiteLLM_ShadowEvalJob {
created_at DateTime @default(now())
created_by String? // user_id of the key that created this job
ends_at DateTime?
completed_at DateTime?
@@index([team_id, status])

View file

@ -172,7 +172,17 @@ class StartShadowEvalRequest(BaseModel):
)
judge_model: str = Field(
default=DEFAULT_SHADOW_EVAL_JUDGE_MODEL,
description="Model used to blindly judge real vs. shadow responses",
description=(
"Model used to blindly judge real vs. shadow responses. The judge only compares two answers, so a "
"mid-tier model (Claude Sonnet or GPT-4o class) is the sweet spot: small/nano-class models produce "
"unreliable or malformed verdicts, while frontier reasoning models add cost without changing outcomes."
),
)
duration_days: int = Field(
default=7,
ge=1,
le=30,
description="How many days the job samples traffic before stopping itself",
)
team_id: str | None = Field(default=None, description="Team the shadowed key belongs to, for authorization")
@ -230,4 +240,7 @@ class GetShadowEvalJobResponse(BaseModel):
cost_estimate: float | None = None
cost_actual: float = Field(default=0.0, description="Running total of judge-call spend for this job")
created_at: str
ends_at: str | None = Field(
default=None, description="When the job stops sampling on its own; null for jobs started before durations"
)
completed_at: str | None = None

View file

@ -1473,6 +1473,7 @@ model LiteLLM_ShadowEvalJob {
created_at DateTime @default(now())
created_by String? // user_id of the key that created this job
ends_at DateTime?
completed_at DateTime?
@@index([team_id, status])

View file

@ -2,6 +2,7 @@
import asyncio
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -601,6 +602,92 @@ class TestPerJobSpendCap:
assert prisma.db.litellm_shadowevaljob.update_many.await_count == 1
@pytest.mark.asyncio
class TestJobsStopAtTheirScheduledEnd:
"""A shadow eval samples ongoing traffic, so a job whose window has closed must
stop billing judge calls even if nobody remembers to stop it by hand."""
@staticmethod
def _job(ends_at):
return ActiveShadowEvalJob(
id="j1",
router_name="r",
shadow_percentage=100.0,
judge_model="m",
status="running",
cost_estimate=10.0,
cost_actual=0.0,
ends_at=ends_at,
)
async def test_job_past_its_end_stops_sampling_and_completes(self):
logger, prisma, router = _logger_with_mocks(self._job(datetime.now(timezone.utc) - timedelta(seconds=1)))
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
logger._run_shadow_eval = AsyncMock()
await logger.async_log_success_event(TestPerJobSpendCap._success_kwargs(), MagicMock(), None, None)
await asyncio.sleep(0)
logger._run_shadow_eval.assert_not_awaited()
router.acompletion.assert_not_called()
call_kwargs = prisma.db.litellm_shadowevaljob.update_many.call_args.kwargs
assert call_kwargs["where"]["id"] == "j1"
assert set(call_kwargs["where"]["status"]["in"]) == {"pending", "running"}
assert call_kwargs["data"]["status"] == "completed"
async def test_job_inside_its_window_keeps_evaluating(self):
logger, prisma, _ = _logger_with_mocks(self._job(datetime.now(timezone.utc) + timedelta(hours=1)))
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
logger._run_shadow_eval = AsyncMock()
await logger.async_log_success_event(TestPerJobSpendCap._success_kwargs(), MagicMock(), None, None)
await asyncio.sleep(0.01)
logger._run_shadow_eval.assert_awaited_once()
prisma.db.litellm_shadowevaljob.update_many.assert_not_awaited()
async def test_job_without_an_end_is_not_expired(self):
logger, prisma, _ = _logger_with_mocks(self._job(None))
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
logger._run_shadow_eval = AsyncMock()
await logger.async_log_success_event(TestPerJobSpendCap._success_kwargs(), MagicMock(), None, None)
await asyncio.sleep(0.01)
logger._run_shadow_eval.assert_awaited_once()
async def test_expiry_evicts_the_cached_job_so_later_requests_do_not_rewrite_it(self):
logger, prisma, _ = _logger_with_mocks(self._job(datetime.now(timezone.utc) - timedelta(seconds=1)))
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
prisma.db.litellm_shadowevaljob.find_first = AsyncMock(return_value=None)
await logger.async_log_success_event(TestPerJobSpendCap._success_kwargs(), MagicMock(), None, None)
await logger.async_log_success_event(TestPerJobSpendCap._success_kwargs(), MagicMock(), None, None)
assert prisma.db.litellm_shadowevaljob.update_many.await_count == 1
async def test_naive_db_datetime_is_treated_as_utc(self):
logger, prisma, _ = _logger_with_mocks()
record = MagicMock()
record.id = "j1"
record.router_name = "r"
record.shadow_percentage = 100.0
record.judge_model = "m"
record.status = "running"
record.cost_estimate = 10.0
record.cost_actual = 0.0
record.ends_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=1)
prisma.db.litellm_shadowevaljob.find_first = AsyncMock(return_value=record)
job = await logger._get_active_job("key-hash")
assert job is not None
assert job.ends_at is not None and job.ends_at.tzinfo is not None
from litellm.integrations.shadow_eval_logger import _job_is_past_its_end
assert _job_is_past_its_end(job)
class TestExtractResponseText:
def test_dict_response(self):
resp = {"choices": [{"message": {"content": "hello"}}]}

View file

@ -4,6 +4,8 @@ Unit tests for auto router management endpoints
import os
import sys
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
@ -21,10 +23,11 @@ from litellm.proxy.management_endpoints.auto_router_endpoints import (
preview_auto_router_routing,
)
from litellm.router import Router
from litellm.types.utils import Choices, Message, ModelResponse
from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterRoutingTestRequest,
StartShadowEvalRequest,
)
from litellm.types.utils import Choices, Message, ModelResponse
ADMIN = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="admin")
@ -55,7 +58,7 @@ def _request(prompt: str, **config_overrides: object) -> AutoRouterRoutingTestRe
async def _route(prompt: str, monkeypatch: pytest.MonkeyPatch, **config_overrides: object):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", _router())
return await preview_auto_router_routing(
@ -119,7 +122,7 @@ async def test_tier_model_missing_from_the_proxy_is_reported(monkeypatch: pytest
@pytest.mark.asyncio
async def test_llm_classifier_call_is_billed_to_the_calling_key(monkeypatch: pytest.MonkeyPatch):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
router = _router()
calls: list[dict] = []
@ -164,7 +167,7 @@ async def test_llm_classifier_call_is_billed_to_the_calling_key(monkeypatch: pyt
async def test_a_key_that_cannot_call_the_classifier_model_is_rejected_before_it_is_called(
monkeypatch: pytest.MonkeyPatch, config_overrides: dict
):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
router = _router()
calls: list[dict] = []
@ -194,7 +197,7 @@ async def test_a_key_that_cannot_call_the_classifier_model_is_rejected_before_it
@pytest.mark.asyncio
async def test_a_key_over_its_budget_cannot_run_a_classifier_config(monkeypatch: pytest.MonkeyPatch):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
router = _router()
calls: list[dict] = []
@ -228,7 +231,7 @@ async def test_a_key_over_its_budget_cannot_run_a_classifier_config(monkeypatch:
@pytest.mark.asyncio
async def test_a_heuristic_config_does_not_need_a_budget(monkeypatch: pytest.MonkeyPatch):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", _router())
@ -249,7 +252,7 @@ async def test_a_heuristic_config_does_not_need_a_budget(monkeypatch: pytest.Mon
@pytest.mark.asyncio
async def test_no_llm_router_on_the_proxy_is_a_500(monkeypatch: pytest.MonkeyPatch):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", None)
@ -261,7 +264,7 @@ async def test_no_llm_router_on_the_proxy_is_a_500(monkeypatch: pytest.MonkeyPat
@pytest.mark.asyncio
async def test_non_admin_without_a_team_is_rejected(monkeypatch: pytest.MonkeyPatch):
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", _router())
@ -543,3 +546,95 @@ class TestJudgeCostEstimate:
)
)
assert _estimate_judge_cost_per_call(self.JUDGE_MODEL) > stale
class TestShadowEvalJobsAreTimeBound:
"""A shadow eval samples ongoing traffic, so without an end date a forgotten job
would keep billing judge calls indefinitely. Every job gets an ends_at, and the
upfront estimate must price the requested window, not always one week."""
@staticmethod
def _start_request(**overrides: object) -> StartShadowEvalRequest:
return StartShadowEvalRequest.model_validate(
{
"api_key_id": "hashed-key",
"router_name": "claude-auto",
"shadow_percentage": 10.0,
**overrides,
}
)
@staticmethod
def _proxy_mocks(monkeypatch: pytest.MonkeyPatch, recent_requests: int) -> MagicMock:
from litellm.proxy import proxy_server
router = MagicMock()
router.auto_routers = {}
router.complexity_routers = {"claude-auto": [MagicMock()]}
router.adaptive_routers = {}
router.quality_routers = {}
prisma = MagicMock()
prisma.db.litellm_spendlogs.count = AsyncMock(return_value=recent_requests)
prisma.db.litellm_shadowevaljob.find_first = AsyncMock(return_value=None)
created = MagicMock()
created.id = "job-1"
prisma.db.litellm_shadowevaljob.create = AsyncMock(return_value=created)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
return prisma
def test_duration_defaults_to_a_week_and_rejects_zero_and_over_a_month(self):
assert self._start_request().duration_days == 7
with pytest.raises(ValidationError):
self._start_request(duration_days=0)
with pytest.raises(ValidationError):
self._start_request(duration_days=31)
@pytest.mark.asyncio
async def test_job_is_created_with_ends_at_duration_days_from_now(self, monkeypatch: pytest.MonkeyPatch):
from litellm.proxy.management_endpoints.auto_router_endpoints import start_shadow_eval
prisma = self._proxy_mocks(monkeypatch, recent_requests=700)
before = datetime.now(timezone.utc)
await start_shadow_eval(self._start_request(duration_days=3), ADMIN)
after = datetime.now(timezone.utc)
ends_at = prisma.db.litellm_shadowevaljob.create.call_args.kwargs["data"]["ends_at"]
assert before + timedelta(days=3) <= ends_at <= after + timedelta(days=3)
@pytest.mark.asyncio
async def test_estimate_scales_with_duration_not_always_a_week(self, monkeypatch: pytest.MonkeyPatch):
from litellm.proxy.management_endpoints.auto_router_endpoints import start_shadow_eval
self._proxy_mocks(monkeypatch, recent_requests=7000)
one_day = await start_shadow_eval(self._start_request(duration_days=1), ADMIN)
self._proxy_mocks(monkeypatch, recent_requests=7000)
two_weeks = await start_shadow_eval(self._start_request(duration_days=14), ADMIN)
assert one_day.estimated_request_count == 100
assert two_weeks.estimated_request_count == 1400
assert two_weeks.estimated_cost == pytest.approx(one_day.estimated_cost * 14, rel=0.02)
def test_response_surfaces_ends_at(self):
from litellm.proxy.management_endpoints.auto_router_endpoints import _job_to_response
fields = {
"id": "job-1",
"status": "running",
"router_name": "claude-auto",
"api_key_id": "hashed-key",
"team_id": None,
"shadow_percentage": 10.0,
"request_count": 0,
"completed_count": 0,
"failed_count": 0,
"cost_estimate": 1.0,
"cost_actual": 0.0,
"created_at": datetime(2026, 8, 1, tzinfo=timezone.utc),
"ends_at": datetime(2026, 8, 8, tzinfo=timezone.utc),
"completed_at": None,
}
response = _job_to_response(type("Row", (), fields)(), None)
assert response.ends_at == "2026-08-08T00:00:00+00:00"

View file

@ -10,6 +10,30 @@ vi.mock("./useShadowEval", () => ({
useStopShadowEval: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: vi.fn(() => ({
data: {
keys: [
{ token: "hash-alpha", token_id: "id-1", key_name: "sk-...alpha", key_alias: "prod-alpha" },
{ token: "hash-beta", token_id: "id-2", key_name: "sk-...beta", key_alias: "staging-beta" },
],
total_count: 2,
current_page: 1,
total_pages: 1,
},
isPending: false,
})),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAutoRouters: vi.fn(() => ({
data: [
{ model_name: "claude-auto", litellm_params: { model: "auto_router/claude-auto" } },
{ model_name: "gpt-auto", litellm_params: { model: "auto_router/gpt-auto" } },
],
})),
}));
import ShadowEvalSection from "./ShadowEvalSection";
import {
useShadowEvalJob,
@ -52,7 +76,10 @@ const job = (overrides: Partial<ShadowEvalJob> = {}): ShadowEvalJob => ({
cost_estimate: 45.0,
cost_actual: 3.21,
created_at: "2026-08-07T00:00:00Z",
ends_at: null,
completed_at: null,
api_key_id: "hashed-key-abc",
team_id: null,
...overrides,
});
@ -68,12 +95,13 @@ const mockHooks = ({
vi.mocked(useShadowEvalJobs).mockReturnValue({ data: jobs, error: null } as unknown as ReturnType<
typeof useShadowEvalJobs
>);
vi.mocked(useShadowEvalJob).mockImplementation(
(_token, jobId) =>
({
data: detailsById ? (jobId ? detailsById[jobId] : undefined) : detail,
}) as unknown as ReturnType<typeof useShadowEvalJob>,
);
vi.mocked(useShadowEvalJob).mockImplementation((_token, jobId) => {
let data = detail;
if (detailsById) {
data = jobId ? detailsById[jobId] : undefined;
}
return { data } as unknown as ReturnType<typeof useShadowEvalJob>;
});
vi.mocked(useStartShadowEval).mockReturnValue({
mutate: vi.fn(),
isPending: false,
@ -131,16 +159,70 @@ describe("ShadowEvalSection", () => {
expect(screen.getByText("Start a shadow eval")).toBeInTheDocument();
});
it("starts a job from picked key + router with duration, submitting the key token not its alias", async () => {
const user = userEvent.setup();
const mutate = vi.fn();
mockHooks({ jobs: [] });
vi.mocked(useStartShadowEval).mockReturnValue({ mutate, isPending: false, error: null } as unknown as ReturnType<
typeof useStartShadowEval
>);
render(<ShadowEvalSection accessToken="token" />);
await user.click(screen.getByPlaceholderText("Search keys by alias"));
await user.click(await screen.findByText("prod-alpha"));
await user.click(screen.getByPlaceholderText("Select an auto-router"));
await user.click(await screen.findByText("gpt-auto"));
await user.click(screen.getByText("Start shadow eval"));
const expectedBody = {
api_key_id: "hash-alpha",
router_name: "gpt-auto",
shadow_percentage: 10,
duration_days: 7,
judge_model: "anthropic/claude-sonnet-5",
};
expect(mutate).toHaveBeenCalledWith({ body: expectedBody });
});
it("filters the key list as you type via the server-side alias search", async () => {
const user = userEvent.setup();
const { useKeys } = await import("@/app/(dashboard)/hooks/keys/useKeys");
mockHooks({ jobs: [] });
render(<ShadowEvalSection accessToken="token" />);
await user.type(screen.getByPlaceholderText("Search keys by alias"), "prod");
await vi.waitFor(() => {
const calls = vi.mocked(useKeys).mock.calls;
expect(calls[calls.length - 1][2]).toMatchObject({ selectedKeyAlias: "prod" });
});
});
it("explains what the judge model is for and recommends a tier", () => {
mockHooks({ jobs: [] });
render(<ShadowEvalSection accessToken="token" />);
expect(screen.getByText("Judge model")).toBeInTheDocument();
expect(screen.getByText(/mid-tier model \(Claude Sonnet or GPT-4o class\)/)).toBeInTheDocument();
});
it("shows when an active job will end", () => {
const j = job({ ends_at: new Date(Date.now() + 3 * 86_400_000).toISOString() });
mockHooks({ jobs: [j], detail: j });
render(<ShadowEvalSection accessToken="token" />);
expect(screen.getByText(/ends in 3 days/)).toBeInTheDocument();
});
it("keeps an older populated job reachable when the newest job has no verdicts", async () => {
const user = userEvent.setup();
const empty = job({
const emptyOverrides: Partial<ShadowEvalJob> = {
job_id: "job-new",
status: "pending",
completed_count: 0,
failed_count: 0,
results: null,
cost_actual: 0,
});
};
const empty = job(emptyOverrides);
const older = job({ job_id: "job-old", status: "completed" });
mockHooks({
jobs: [empty, older],

View file

@ -2,10 +2,16 @@
import React, { useMemo, useState } from "react";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ApiError } from "@/lib/http/client";
@ -21,6 +27,17 @@ import {
const pct = (value: number): string => `${value.toFixed(1)}%`;
const endsIn = (endsAt: string | null | undefined): string | null => {
if (!endsAt) return null;
const remainingMs = new Date(endsAt).getTime() - Date.now();
if (Number.isNaN(remainingMs)) return null;
if (remainingMs <= 0) return "ending now";
const days = Math.round(remainingMs / 86_400_000);
if (days >= 2) return `ends in ${days} days`;
const hours = Math.round(remainingMs / 3_600_000);
return hours >= 2 ? `ends in ${hours} hours` : "ends within the hour";
};
const STATUS_STYLES: Record<string, string> = {
pending: "bg-secondary text-muted-foreground",
running: "bg-blue-50 text-blue-700",
@ -91,7 +108,8 @@ const JobResults: React.FC<{
<p className="text-xs text-muted-foreground">
{job.completed_count.toLocaleString()} judged · {job.failed_count.toLocaleString()} failed ·{" "}
{job.cost_actual != null ? `${usd(job.cost_actual)} judge spend` : "no judge spend yet"}
{job.cost_estimate != null ? ` (est. ${usd(job.cost_estimate)}/wk)` : ""}
{job.cost_estimate != null ? ` (est. ${usd(job.cost_estimate)})` : ""}
{active && endsIn(job.ends_at) ? ` · ${endsIn(job.ends_at)}` : ""}
</p>
</div>
</div>
@ -132,13 +150,61 @@ const JobResults: React.FC<{
/** Kept in sync with DEFAULT_SHADOW_EVAL_JUDGE_MODEL on the backend. */
const DEFAULT_JUDGE_MODEL = "anthropic/claude-sonnet-5";
const DURATION_OPTIONS = [
{ value: "1", label: "1 day" },
{ value: "3", label: "3 days" },
{ value: "7", label: "7 days" },
{ value: "14", label: "14 days" },
{ value: "30", label: "30 days" },
] as const;
const DEFAULT_DURATION_DAYS = "7";
const KEY_PAGE_SIZE = 50;
const KeySelect: React.FC<{ value: string; onChange: (token: string) => void }> = ({ value, onChange }) => {
const [search, setSearch] = useState("");
const { data, isPending } = useKeys(1, KEY_PAGE_SIZE, { selectedKeyAlias: search || null });
const options = useMemo<SearchSelectOption[]>(
() =>
(data?.keys ?? []).map((key) => ({
label: key.key_alias || key.key_name || key.token,
value: key.token,
sublabel: key.token,
})),
[data],
);
return (
<PaginatedSearchSelect
inputId="shadow-eval-key"
options={options}
value={value}
onValueChange={onChange}
onSearchChange={setSearch}
onLoadMore={() => {}}
isLoading={isPending}
placeholder="Search keys by alias"
emptyText="No matching keys"
/>
);
};
const StartForm: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
const [apiKeyId, setApiKeyId] = useState("");
const [routerName, setRouterName] = useState("");
const [percentage, setPercentage] = useState("10");
const [durationDays, setDurationDays] = useState(DEFAULT_DURATION_DAYS);
const [judgeModel, setJudgeModel] = useState(DEFAULT_JUDGE_MODEL);
const { data: autoRouters } = useAutoRouters();
const start = useStartShadowEval();
const routerOptions = useMemo<SearchSelectOption[]>(() => {
const names = new Set<string>();
for (const deployment of autoRouters ?? []) {
if (deployment.model_name) names.add(deployment.model_name);
}
return [...names].sort().map((name) => ({ label: name, value: name }));
}, [autoRouters]);
const parsedPct = Number.parseFloat(percentage);
const valid =
Boolean(accessToken) && apiKeyId.trim() !== "" && routerName.trim() !== "" && parsedPct > 0 && parsedPct <= 100;
@ -149,41 +215,84 @@ const StartForm: React.FC<{ accessToken: string | null }> = ({ accessToken }) =>
<CardTitle className="text-sm font-medium text-foreground">Start a shadow eval</CardTitle>
<p className="text-xs text-muted-foreground">
Duplicates a sampled slice of the key&apos;s traffic through the auto-router and has an LLM judge compare both
answers blind. The router&apos;s answers are never served to users. Judge calls bill to the proxy an
estimate is shown before anything runs.
answers blind. The router&apos;s answers are never served to users. Judge calls bill to the shadowed key an
estimate is shown before anything runs, and the job stops itself when the duration ends.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<Input
placeholder="Key hash (token) to shadow"
value={apiKeyId}
onChange={(e) => setApiKeyId(e.target.value)}
/>
<Input
placeholder="Auto-router name (e.g. claude-auto)"
value={routerName}
onChange={(e) => setRouterName(e.target.value)}
/>
<div className="flex items-center gap-2">
<Input
type="number"
min={0.1}
max={100}
step={0.1}
className="w-24"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
<div className="space-y-1.5">
<Label htmlFor="shadow-eval-key" className="text-xs">
Key to shadow
</Label>
<KeySelect value={apiKeyId} onChange={setApiKeyId} />
</div>
<div className="space-y-1.5">
<Label htmlFor="shadow-eval-router" className="text-xs">
Auto-router
</Label>
<SearchSelect
options={routerOptions}
value={routerName}
onValueChange={setRouterName}
placeholder="Select an auto-router"
emptyText="No auto-routers configured"
/>
<span className="text-sm text-muted-foreground">% of traffic</span>
</div>
<div className="space-y-1.5">
<Label htmlFor="shadow-eval-pct" className="text-xs">
Traffic sampled
</Label>
<div className="flex items-center gap-2">
<Input
id="shadow-eval-pct"
type="number"
min={0.1}
max={100}
step={0.1}
className="w-24"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
/>
<span className="text-sm text-muted-foreground">% of traffic</span>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Input
placeholder={`Judge model (default: ${DEFAULT_JUDGE_MODEL})`}
value={judgeModel}
onChange={(e) => setJudgeModel(e.target.value)}
/>
<div className="space-y-1.5">
<Label className="text-xs">Duration</Label>
<Select
value={durationDays}
onValueChange={(v: string | null) => setDurationDays(v ?? DEFAULT_DURATION_DAYS)}
>
<SelectTrigger className="w-full">
<SelectValue>{DURATION_OPTIONS.find((o) => o.value === durationDays)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="shadow-eval-judge" className="text-xs">
Judge model
</Label>
<Input
id="shadow-eval-judge"
placeholder={`Default: ${DEFAULT_JUDGE_MODEL}`}
value={judgeModel}
onChange={(e) => setJudgeModel(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The judge only compares two answers blind a mid-tier model (Claude Sonnet or GPT-4o class) is the sweet
spot. Small &quot;nano/mini&quot; models give unreliable verdicts; frontier reasoning models cost more
without changing outcomes.
</p>
</div>
</div>
{start.error ? (
<p className="text-sm text-destructive">
@ -198,6 +307,7 @@ const StartForm: React.FC<{ accessToken: string | null }> = ({ accessToken }) =>
api_key_id: apiKeyId.trim(),
router_name: routerName.trim(),
shadow_percentage: parsedPct,
duration_days: Number.parseInt(durationDays, 10),
judge_model: judgeModel.trim() || DEFAULT_JUDGE_MODEL,
},
})

View file

@ -842,10 +842,11 @@ export interface paths {
* traffic through an auto-router, judge real vs. shadow responses blind, and
* stratify win rates by the router's tier classification.
*
* The shadow responses are never served to users. The job stays active until
* stopped via /auto_router/shadow_eval/{job_id}/stop. Judge calls bill to the
* proxy; the estimate returned here prices them from the key's trailing
* request volume.
* The shadow responses are never served to users. The job samples traffic for
* duration_days (or until stopped via /auto_router/shadow_eval/{job_id}/stop),
* then completes itself. Judge calls bill to the shadowed key; the estimate
* returned here prices them from the key's trailing request volume scaled to
* the requested duration.
*/
post: operations["start_shadow_eval_auto_router_shadow_eval_start_post"];
delete?: never;
@ -25351,6 +25352,11 @@ export interface components {
cost_estimate?: number | null;
/** Created At */
created_at: string;
/**
* Ends At
* @description When the job stops sampling on its own; null for jobs started before durations
*/
ends_at?: string | null;
/**
* Failed Count
* @description Shadow or judge calls that errored and were skipped
@ -32781,9 +32787,15 @@ export interface components {
* @description The hashed virtual key whose traffic will be shadowed. Shadow evaluation runs ONLY on this key's traffic; requests made with any other key are not sampled.
*/
api_key_id: string;
/**
* Duration Days
* @description How many days the job samples traffic before stopping itself
* @default 7
*/
duration_days: number;
/**
* Judge Model
* @description Model used to blindly judge real vs. shadow responses
* @description Model used to blindly judge real vs. shadow responses. The judge only compares two answers, so a mid-tier model (Claude Sonnet or GPT-4o class) is the sweet spot: small/nano-class models produce unreliable or malformed verdicts, while frontier reasoning models add cost without changing outcomes.
* @default anthropic/claude-sonnet-5
*/
judge_model: string;

File diff suppressed because one or more lines are too long