mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix: judge model validation, internal-call exclusion from adoption metrics, loop-owned job lifecycle
This commit is contained in:
parent
b2feff3f05
commit
ece7b16d3c
18 changed files with 724 additions and 283 deletions
|
|
@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS "LiteLLM_ShadowEvalJob" (
|
|||
"request_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"completed_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"result_json" JSONB,
|
||||
"last_error" TEXT,
|
||||
"cost_estimate" DOUBLE PRECISION,
|
||||
"cost_actual" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
|
|||
|
|
@ -1466,10 +1466,7 @@ model LiteLLM_ShadowEvalJob {
|
|||
request_count Int @default(0) // total requests on the shadowed key during the job
|
||||
completed_count Int @default(0) // verdicts written
|
||||
failed_count Int @default(0) // shadow calls or judge calls that failed
|
||||
|
||||
// Results (written incrementally or at end)
|
||||
// { groups: [ { tier: "SIMPLE", turn_count: N, real_win_rate_pct: X, shadow_win_rate_pct: Y, tie_rate_pct: Z, avg_confidence: C } ] }
|
||||
result_json Json?
|
||||
last_error String? // most recent shadow/judge failure, for diagnosing a growing failed_count
|
||||
|
||||
cost_estimate Float? // estimated cost of running the judge
|
||||
cost_actual Float @default(0) // actual cost (judge calls * price)
|
||||
|
|
|
|||
|
|
@ -15,15 +15,21 @@ Flow per sampled request (all in a detached background task, zero added latency)
|
|||
the judge cannot learn a position bias.
|
||||
3. Write a ``LiteLLM_ShadowEvalVerdict`` row and bump the job's counters.
|
||||
|
||||
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 shadow/judge pair is skipped outright if
|
||||
the shadowed key or its team is already at or over budget, read from the same
|
||||
cross-pod spend counters the normal auth path reserves against. 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.
|
||||
Shadow and judge calls carry an ``internal_call_origin`` stamp, the same field
|
||||
every internal sub-call (including the auto-router's own classifier) is marked
|
||||
with, and the logger skips any request carrying it: its own traffic cannot
|
||||
recurse, and internal sub-calls billed to the shadowed key are never judged as
|
||||
if a user sent them. The calls 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 shadow/judge pair is skipped outright if the shadowed
|
||||
key or its team is already at or over budget, read from the same cross-pod
|
||||
spend counters the normal auth path reserves against.
|
||||
|
||||
Job lifecycle (counter flushes, snapshot refresh, stopping a job at its
|
||||
``ends_at`` or judge-spend cap) runs on a periodic loop started at proxy
|
||||
startup, never on the request path: an idle key's job still ends on schedule,
|
||||
and the final counter batch lands without needing another request to arrive.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -41,6 +47,7 @@ from pydantic import BaseModel, TypeAdapter
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.internal_call_metadata import sanitized_forwardable_call_metadata
|
||||
from litellm.types.utils import SHADOW_EVAL_JUDGE_CALL_ORIGIN, SHADOW_EVAL_ROUTER_CALL_ORIGIN
|
||||
|
|
@ -52,13 +59,10 @@ if TYPE_CHECKING:
|
|||
|
||||
_JSON_FENCE_RE: Final = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# Metadata marker that tags shadow/judge calls made by this logger, so the
|
||||
# success hook skips them instead of shadowing the shadow.
|
||||
SHADOW_EVAL_INTERNAL_MARKER: Final = "shadow_eval_internal"
|
||||
|
||||
# How long the active-job snapshot is cached. A shadow-eval job starting
|
||||
# or stopping takes up to this long to be noticed by running pods.
|
||||
_JOB_CACHE_TTL_SECONDS: Final = 30.0
|
||||
# Cadence of the lifecycle loop: snapshot refresh, counter flush, and job
|
||||
# finalization all run on this tick. A job starting or stopping takes up to
|
||||
# one tick to be noticed by running pods.
|
||||
_LIFECYCLE_TICK_SECONDS: Final = 10.0
|
||||
|
||||
# Upper bound on concurrent shadow+judge pipelines per pod, so a traffic spike
|
||||
# turns into skipped samples rather than an unbounded task pileup.
|
||||
|
|
@ -73,13 +77,14 @@ _MAX_JUDGE_CHARS: Final = 16_000
|
|||
# to failed_count, while a larger one buys nothing but cost exposure.
|
||||
JUDGE_MAX_OUTPUT_TOKENS: Final = 500
|
||||
|
||||
_SEEN_FLUSH_INTERVAL_SECONDS: Final = 10.0
|
||||
# Persisted diagnosis bound: the most recent shadow/judge failure, truncated.
|
||||
_MAX_LAST_ERROR_CHARS: Final = 500
|
||||
|
||||
# A job stops sampling once its judge spend reaches this multiple of the estimate shown
|
||||
# at start. The headroom absorbs an estimate that undershot the real traffic mix without
|
||||
# letting the job run away; the floor keeps a cent-sized estimate from stopping a job on
|
||||
# its first verdict. cost_actual is read from the job cache, so overshoot is bounded by
|
||||
# _JOB_CACHE_TTL_SECONDS worth of judge calls.
|
||||
# its first verdict. cost_actual is read from the job snapshot, so overshoot is bounded
|
||||
# by _LIFECYCLE_TICK_SECONDS worth of judge calls.
|
||||
_SPEND_CAP_MULTIPLIER: Final = 1.5
|
||||
_SPEND_CAP_FLOOR_USD: Final = 1.0
|
||||
|
||||
|
|
@ -165,6 +170,33 @@ def _unmask_preference(raw_preference: str, real_is_a: bool) -> str:
|
|||
return "tie"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CallFailure:
|
||||
"""A shadow or judge call that produced no usable response, with why."""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ShadowResponse:
|
||||
"""A successful shadow call: (text, routed model, tier, completion tokens)."""
|
||||
|
||||
text: str
|
||||
model: str
|
||||
tier: str | None
|
||||
completion_tokens: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _JudgeVerdict:
|
||||
"""A parsed judge verdict, unmasked back to real/shadow/tie."""
|
||||
|
||||
preference: str
|
||||
confidence: float
|
||||
reasoning: str
|
||||
cost: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActiveShadowEvalJob:
|
||||
"""The subset of a shadow-eval job row the request path actually needs."""
|
||||
|
|
@ -279,15 +311,44 @@ class ShadowEvalLogger(CustomLogger):
|
|||
self._router_provider = router_provider or _default_router_provider
|
||||
self._prisma_provider = prisma_provider or _default_prisma_provider
|
||||
# Snapshot of every active job, keyed by shadowed api_key_id. Refreshed as a
|
||||
# whole: active jobs are admin-started and capped at one per key, so the set is
|
||||
# tiny, and one find_many per pod per TTL keeps DB load flat no matter how many
|
||||
# distinct keys the proxy serves.
|
||||
self._jobs_by_key: dict[str, ActiveShadowEvalJob] = {} # mutable-ok: TTL cache
|
||||
self._jobs_fetched_at: float | None = None
|
||||
self._jobs_refresh_task: asyncio.Task[None] | None = None
|
||||
# whole by the lifecycle loop: active jobs are admin-started and capped at one
|
||||
# per key, so the set is tiny, and one find_many per pod per tick keeps DB load
|
||||
# flat no matter how many distinct keys the proxy serves.
|
||||
self._jobs_by_key: dict[str, ActiveShadowEvalJob] = {} # mutable-ok: loop-refreshed snapshot
|
||||
self._inflight_shadow_tasks: int = 0
|
||||
self._pending_seen: dict[str, int] = {} # mutable-ok: flush buffer
|
||||
self._last_seen_flush: float = 0.0
|
||||
self._lifecycle_task: asyncio.Task[None] | None = None
|
||||
|
||||
def start_lifecycle_loop(self) -> None:
|
||||
"""Idempotently start the periodic loop that owns job lifecycle off the request path."""
|
||||
if self._lifecycle_task is not None and not self._lifecycle_task.done():
|
||||
return
|
||||
self._lifecycle_task = asyncio.create_task(self._lifecycle_loop())
|
||||
|
||||
async def _lifecycle_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._lifecycle_tick()
|
||||
except Exception as e: # noqa: BLE001 # the loop must survive any single tick failing
|
||||
verbose_logger.debug("shadow_eval: lifecycle tick failed: %s", e)
|
||||
await asyncio.sleep(_LIFECYCLE_TICK_SECONDS)
|
||||
|
||||
async def _lifecycle_tick(self) -> None:
|
||||
"""One tick: flush counters while jobs are still active, refresh, then finalize.
|
||||
|
||||
Flushing before finalizing lets an expiring job's last counter batch land while
|
||||
its row still passes the active-status guard.
|
||||
"""
|
||||
await self._flush_seen_counts()
|
||||
await self._refresh_active_jobs()
|
||||
for job in tuple(self._jobs_by_key.values()):
|
||||
if _job_is_past_its_end(job):
|
||||
await self._finalize_job(job, "reached its scheduled end")
|
||||
elif _job_is_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",
|
||||
)
|
||||
|
||||
#### hook ####
|
||||
|
||||
|
|
@ -310,33 +371,22 @@ class ShadowEvalLogger(CustomLogger):
|
|||
request_metadata: Final = (
|
||||
raw_request_metadata if isinstance(raw_request_metadata, Mapping) else _EMPTY_METADATA
|
||||
)
|
||||
if request_metadata.get(SHADOW_EVAL_INTERNAL_MARKER):
|
||||
return # our own shadow/judge traffic
|
||||
if request_metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY):
|
||||
return # internal sub-call (our own shadow/judge, a classifier, ...), not user traffic
|
||||
api_key_hash: Final = metadata.get("user_api_key_hash")
|
||||
if not api_key_hash:
|
||||
return
|
||||
job: Final = self._get_active_job(api_key_hash)
|
||||
job: Final = self._jobs_by_key.get(str(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._finalize_job(
|
||||
job,
|
||||
f"spend ${job.cost_actual:.4f} reached the cap for its ${job.cost_estimate or 0.0:.4f} estimate",
|
||||
)
|
||||
return
|
||||
if _job_is_past_its_end(job) or _job_is_over_spend_cap(job):
|
||||
return # stop sampling now; the lifecycle loop finalizes the row
|
||||
request_id: Final = payload.get("id") or ""
|
||||
if not request_id:
|
||||
return
|
||||
# The job tracks every request it saw, sampled or not, so the UI can
|
||||
# show "N of M requests shadowed".
|
||||
# show "N of M requests shadowed". Flushed by the lifecycle loop.
|
||||
self._pending_seen[job.id] = self._pending_seen.get(job.id, 0) + 1
|
||||
now: Final = asyncio.get_event_loop().time()
|
||||
if now - self._last_seen_flush >= _SEEN_FLUSH_INTERVAL_SECONDS:
|
||||
self._last_seen_flush = now
|
||||
asyncio.create_task(self._flush_seen_counts())
|
||||
if not _sample_hits(request_id, job.id, job.shadow_percentage):
|
||||
return
|
||||
if payload.get("call_type") not in (None, "completion", "acompletion", "chat_completion"):
|
||||
|
|
@ -369,23 +419,9 @@ class ShadowEvalLogger(CustomLogger):
|
|||
|
||||
#### job lookup ####
|
||||
|
||||
def _get_active_job(self, api_key_hash: str) -> ActiveShadowEvalJob | None:
|
||||
"""Serve from the snapshot; never await the DB on the request path.
|
||||
|
||||
An expired (or missing) snapshot kicks a detached refresh and this request is
|
||||
answered from whatever is already in memory — possibly stale, on a cold pod
|
||||
possibly empty. A sampled eval tolerates a missed slice far better than every
|
||||
request on the proxy tolerating a synchronous Prisma read in its success hook.
|
||||
"""
|
||||
now: Final = asyncio.get_event_loop().time()
|
||||
expired: Final = self._jobs_fetched_at is None or now - self._jobs_fetched_at >= _JOB_CACHE_TTL_SECONDS
|
||||
if expired and (self._jobs_refresh_task is None or self._jobs_refresh_task.done()):
|
||||
self._jobs_refresh_task = asyncio.create_task(self._refresh_active_jobs(now))
|
||||
return self._jobs_by_key.get(api_key_hash)
|
||||
|
||||
async def _refresh_active_jobs(self, now: float) -> None:
|
||||
async def _refresh_active_jobs(self) -> None:
|
||||
"""Reload the active-job set. On a DB blip the stale snapshot is kept and the
|
||||
next TTL retries, so a blip degrades freshness rather than turning the feature off."""
|
||||
next tick retries, so a blip degrades freshness rather than turning the feature off."""
|
||||
prisma: Final = self._prisma_provider()
|
||||
if prisma is None:
|
||||
return
|
||||
|
|
@ -410,7 +446,6 @@ class ShadowEvalLogger(CustomLogger):
|
|||
ends_at=_as_utc(getattr(record, "ends_at", None)),
|
||||
)
|
||||
self._jobs_by_key = jobs_by_key # mutable-ok: atomic snapshot swap
|
||||
self._jobs_fetched_at = now
|
||||
|
||||
async def _finalize_job(self, job: ActiveShadowEvalJob, reason: str) -> None:
|
||||
"""Flip a finished job to completed, keeping the verdicts it already produced.
|
||||
|
|
@ -440,15 +475,24 @@ class ShadowEvalLogger(CustomLogger):
|
|||
verbose_logger.debug("shadow_eval: failed to stop job %s: %s", job.id, e)
|
||||
|
||||
async def _flush_seen_counts(self) -> None:
|
||||
"""Write the buffered request counts, guarded on the job still being active.
|
||||
|
||||
The guard makes stopping a job freeze its counter: a pod serving a stale
|
||||
snapshot keeps buffering for up to one tick, and this write drops those
|
||||
increments instead of growing a stopped job's request_count.
|
||||
"""
|
||||
prisma: Final = self._prisma_provider()
|
||||
if prisma is None:
|
||||
if prisma is None or not self._pending_seen:
|
||||
return
|
||||
pending: Final = self._pending_seen
|
||||
self._pending_seen = {} # mutable-ok: fresh flush buffer
|
||||
for job_id, count in pending.items():
|
||||
try:
|
||||
await prisma.db.litellm_shadowevaljob.update(
|
||||
where={"id": job_id}, # mutable-ok: Prisma filter
|
||||
await prisma.db.litellm_shadowevaljob.update_many(
|
||||
where={ # mutable-ok: Prisma filter
|
||||
"id": job_id,
|
||||
"status": {"in": ["pending", "running"]}, # mutable-ok: Prisma filter
|
||||
},
|
||||
data={"request_count": {"increment": count}}, # mutable-ok: Prisma payload
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # counter drift is acceptable; failing the loop is not
|
||||
|
|
@ -482,22 +526,20 @@ class ShadowEvalLogger(CustomLogger):
|
|||
return
|
||||
|
||||
shadow: Final = await self._call_router_shadow(job.router_name, messages, model_parameters, parent_metadata)
|
||||
if shadow is None:
|
||||
await self._bump_failed(job.id)
|
||||
if isinstance(shadow, _CallFailure):
|
||||
await self._bump_failed(job.id, shadow.error)
|
||||
return
|
||||
shadow_text, shadow_model, tier, shadow_tokens = shadow
|
||||
|
||||
verdict: Final = await self._call_judge(
|
||||
judge_model=job.judge_model,
|
||||
messages=messages,
|
||||
real_text=real_text,
|
||||
shadow_text=shadow_text,
|
||||
shadow_text=shadow.text,
|
||||
parent_metadata=parent_metadata,
|
||||
)
|
||||
if verdict is None:
|
||||
await self._bump_failed(job.id)
|
||||
if isinstance(verdict, _CallFailure):
|
||||
await self._bump_failed(job.id, verdict.error)
|
||||
return
|
||||
preference, confidence, reasoning, judge_cost = verdict
|
||||
|
||||
if prisma is None:
|
||||
return
|
||||
|
|
@ -505,13 +547,13 @@ class ShadowEvalLogger(CustomLogger):
|
|||
data={ # mutable-ok: Prisma payload
|
||||
"job_id": job.id,
|
||||
"request_id": request_id,
|
||||
"tier_classification": tier,
|
||||
"tier_classification": shadow.tier,
|
||||
"real_model": real_model,
|
||||
"shadow_model": shadow_model,
|
||||
"shadow_response_tokens": shadow_tokens,
|
||||
"judge_preference": preference,
|
||||
"judge_confidence": confidence,
|
||||
"judge_reasoning": reasoning[:1000] if reasoning else None,
|
||||
"shadow_model": shadow.model,
|
||||
"shadow_response_tokens": shadow.completion_tokens,
|
||||
"judge_preference": verdict.preference,
|
||||
"judge_confidence": verdict.confidence,
|
||||
"judge_reasoning": verdict.reasoning[:1000] if verdict.reasoning else None,
|
||||
"judge_model": job.judge_model,
|
||||
}
|
||||
)
|
||||
|
|
@ -522,22 +564,28 @@ class ShadowEvalLogger(CustomLogger):
|
|||
},
|
||||
data={ # mutable-ok: Prisma payload
|
||||
"completed_count": {"increment": 1}, # mutable-ok: Prisma operator
|
||||
"cost_actual": {"increment": judge_cost}, # mutable-ok: Prisma operator
|
||||
"cost_actual": {"increment": verdict.cost}, # mutable-ok: Prisma operator
|
||||
"status": "running",
|
||||
},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # detached task: log, count, never raise
|
||||
verbose_logger.debug("shadow_eval: pipeline failed for %s: %s", request_id, e)
|
||||
await self._bump_failed(job.id)
|
||||
await self._bump_failed(job.id, f"pipeline error: {e}")
|
||||
|
||||
async def _bump_failed(self, job_id: str) -> None:
|
||||
async def _bump_failed(self, job_id: str, error: str) -> None:
|
||||
prisma: Final = self._prisma_provider()
|
||||
if prisma is None:
|
||||
return
|
||||
try:
|
||||
await prisma.db.litellm_shadowevaljob.update(
|
||||
where={"id": job_id}, # mutable-ok: Prisma filter
|
||||
data={"failed_count": {"increment": 1}}, # mutable-ok: Prisma payload
|
||||
await prisma.db.litellm_shadowevaljob.update_many(
|
||||
where={ # mutable-ok: Prisma filter
|
||||
"id": job_id,
|
||||
"status": {"in": ["pending", "running"]}, # mutable-ok: Prisma filter
|
||||
},
|
||||
data={ # mutable-ok: Prisma payload
|
||||
"failed_count": {"increment": 1}, # mutable-ok: Prisma operator
|
||||
"last_error": error[:_MAX_LAST_ERROR_CHARS],
|
||||
},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # counter drift is acceptable
|
||||
verbose_logger.debug("shadow_eval: failed_count increment failed: %s", e)
|
||||
|
|
@ -548,20 +596,18 @@ class ShadowEvalLogger(CustomLogger):
|
|||
messages: Sequence[Mapping[str, object]],
|
||||
model_parameters: Mapping[str, object],
|
||||
parent_metadata: Mapping[str, object],
|
||||
) -> tuple[str, str, str | None, int | None] | None:
|
||||
"""Send the prompt through the auto-router; return (text, model, tier, completion_tokens)."""
|
||||
) -> "_ShadowResponse | _CallFailure":
|
||||
"""Send the prompt through the auto-router being evaluated."""
|
||||
router: Final = self._router_provider()
|
||||
if router is None:
|
||||
verbose_logger.debug("shadow_eval: no router available")
|
||||
return None
|
||||
return _CallFailure("no router configured on this pod")
|
||||
# Carries the shadowed key's identity so this call's real provider spend is
|
||||
# attributed to, and budget-checked against, the key whose traffic it copies.
|
||||
# The router's pre-routing hook also writes its routing decision into this dict;
|
||||
# read it back after the call for tier attribution.
|
||||
attribution: Final = sanitized_forwardable_call_metadata(parent_metadata, SHADOW_EVAL_ROUTER_CALL_ORIGIN)
|
||||
shadow_metadata: Final[dict[str, object]] = attribution | { # mutable-ok: router writes back
|
||||
SHADOW_EVAL_INTERNAL_MARKER: True
|
||||
}
|
||||
shadow_metadata: Final[dict[str, object]] = sanitized_forwardable_call_metadata( # mutable-ok: router writes back
|
||||
parent_metadata, SHADOW_EVAL_ROUTER_CALL_ORIGIN
|
||||
)
|
||||
shadow_params: Final = { # mutable-ok: splatted as kwargs
|
||||
k: v for k, v in model_parameters.items() if k not in ("stream", "metadata")
|
||||
}
|
||||
|
|
@ -574,19 +620,21 @@ class ShadowEvalLogger(CustomLogger):
|
|||
)
|
||||
except Exception as e: # noqa: BLE001 # provider errors are a counted failure, not a crash
|
||||
verbose_logger.debug("shadow_eval: router call failed: %s", e)
|
||||
return None
|
||||
return _CallFailure(f"shadow router call failed: {e}")
|
||||
text: Final = self._extract_response_text(response)
|
||||
if not text:
|
||||
return None
|
||||
return _CallFailure("shadow router returned an empty response")
|
||||
raw_decision: Final = shadow_metadata.get("routing_decision")
|
||||
routing_decision: Final = raw_decision if isinstance(raw_decision, Mapping) else _EMPTY_METADATA
|
||||
raw_tier: Final = routing_decision.get("tier_label") or routing_decision.get("tier")
|
||||
tier: Final = str(raw_tier) if raw_tier is not None else None
|
||||
model: Final = str(getattr(response, "model", None) or routing_decision.get("routed_model") or "")
|
||||
usage: Final = getattr(response, "usage", None)
|
||||
raw_tokens: Final = getattr(usage, "completion_tokens", None) if usage is not None else None
|
||||
completion_tokens: Final = int(raw_tokens) if isinstance(raw_tokens, int) else None
|
||||
return text, model, tier, completion_tokens
|
||||
return _ShadowResponse(
|
||||
text=text,
|
||||
model=str(getattr(response, "model", None) or routing_decision.get("routed_model") or ""),
|
||||
tier=str(raw_tier) if raw_tier is not None else None,
|
||||
completion_tokens=int(raw_tokens) if isinstance(raw_tokens, int) else None,
|
||||
)
|
||||
|
||||
async def _call_judge(
|
||||
self,
|
||||
|
|
@ -595,8 +643,10 @@ class ShadowEvalLogger(CustomLogger):
|
|||
real_text: str,
|
||||
shadow_text: str,
|
||||
parent_metadata: Mapping[str, object],
|
||||
) -> tuple[str, float, str, float] | None:
|
||||
"""Blind pairwise judge. Returns (preference, confidence, reasoning, cost)."""
|
||||
) -> "_JudgeVerdict | _CallFailure":
|
||||
"""Blind pairwise judge, dispatched through the proxy's router when the judge
|
||||
model is a configured deployment (so DB-stored credentials work), the SDK
|
||||
otherwise (provider-qualified public names with credentials in the env)."""
|
||||
real_is_a: Final = random.random() < 0.5
|
||||
response_a: Final = real_text if real_is_a else shadow_text
|
||||
response_b: Final = shadow_text if real_is_a else real_text
|
||||
|
|
@ -612,32 +662,50 @@ class ShadowEvalLogger(CustomLogger):
|
|||
f"Response B:\n{response_b[:_MAX_JUDGE_CHARS]}\n\n"
|
||||
"Which response is better?"
|
||||
)
|
||||
judge_metadata: Final = sanitized_forwardable_call_metadata(parent_metadata, SHADOW_EVAL_JUDGE_CALL_ORIGIN) | {
|
||||
SHADOW_EVAL_INTERNAL_MARKER: True
|
||||
}
|
||||
judge_metadata: Final = sanitized_forwardable_call_metadata(parent_metadata, SHADOW_EVAL_JUDGE_CALL_ORIGIN)
|
||||
judge_messages: Final = [ # mutable-ok: SDK takes a list
|
||||
{"role": "system", "content": PAIRWISE_JUDGE_SYSTEM_PROMPT}, # mutable-ok: SDK message
|
||||
{"role": "user", "content": user_prompt}, # mutable-ok: SDK message
|
||||
]
|
||||
router: Final = self._router_provider()
|
||||
use_router: Final = router is not None and (
|
||||
judge_model in router.model_group_alias or router.get_model_list(model_name=judge_model)
|
||||
)
|
||||
try:
|
||||
response: Final = await litellm.acompletion(
|
||||
model=judge_model,
|
||||
messages=[ # mutable-ok: SDK takes a list
|
||||
{"role": "system", "content": PAIRWISE_JUDGE_SYSTEM_PROMPT}, # mutable-ok: SDK message
|
||||
{"role": "user", "content": user_prompt}, # mutable-ok: SDK message
|
||||
],
|
||||
temperature=0,
|
||||
max_tokens=JUDGE_MAX_OUTPUT_TOKENS,
|
||||
metadata=judge_metadata,
|
||||
response: Final = (
|
||||
await router.acompletion( # pyright: ignore[reportOptionalMemberAccess] # use_router implies router is not None
|
||||
model=judge_model,
|
||||
messages=judge_messages,
|
||||
temperature=0,
|
||||
max_tokens=JUDGE_MAX_OUTPUT_TOKENS,
|
||||
metadata=judge_metadata,
|
||||
num_retries=0,
|
||||
fallbacks=[],
|
||||
)
|
||||
if use_router
|
||||
else await litellm.acompletion(
|
||||
model=judge_model,
|
||||
messages=judge_messages,
|
||||
temperature=0,
|
||||
max_tokens=JUDGE_MAX_OUTPUT_TOKENS,
|
||||
metadata=judge_metadata,
|
||||
)
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # judge outages are a counted failure, not a crash
|
||||
verbose_logger.debug("shadow_eval: judge call failed: %s", e)
|
||||
return None
|
||||
return _CallFailure(f"judge call failed: {e}")
|
||||
try:
|
||||
raw: Final = response["choices"][0]["message"]["content"] or ""
|
||||
verdict: Final = _parse_pairwise_verdict(raw)
|
||||
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as e:
|
||||
verbose_logger.debug("shadow_eval: unparseable judge verdict: %s", e)
|
||||
return None
|
||||
preference: Final = _unmask_preference(verdict.preference, real_is_a)
|
||||
confidence: Final = max(0.0, min(1.0, verdict.confidence))
|
||||
return preference, confidence, verdict.reasoning, _judge_call_cost(response)
|
||||
return _CallFailure(f"unparseable judge verdict: {e}")
|
||||
return _JudgeVerdict(
|
||||
preference=_unmask_preference(verdict.preference, real_is_a),
|
||||
confidence=max(0.0, min(1.0, verdict.confidence)),
|
||||
reasoning=verdict.reasoning,
|
||||
cost=_judge_call_cost(response),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_text(response_obj: object) -> str:
|
||||
|
|
|
|||
|
|
@ -180,12 +180,17 @@ def build_autorouter_turn_transaction(
|
|||
|
||||
The routing_decision record is what says a request was auto-routed at all, so a
|
||||
request without one (including the auto-router's own classifier sub-calls) never
|
||||
reaches the rollup. Failed requests served nothing and are excluded. Cache facts
|
||||
are derived from the payload's own usage record through the savings owner, never
|
||||
handed in beside it.
|
||||
reaches the rollup. Internal sub-calls that DO carry one (a shadow eval's duplicate
|
||||
of a request through the router) are excluded by their internal_call_origin stamp:
|
||||
they are not traffic a user sent, so counting them would manufacture sessions and
|
||||
savings in the adoption metrics. Failed requests served nothing and are excluded.
|
||||
Cache facts are derived from the payload's own usage record through the savings
|
||||
owner, never handed in beside it.
|
||||
"""
|
||||
if payload.get("status") != "success":
|
||||
return None
|
||||
if metadata.get("internal_call_origin"):
|
||||
return None
|
||||
routing_decision: Final = metadata.get("routing_decision")
|
||||
if not isinstance(routing_decision, Mapping) or not routing_decision:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1794,6 +1794,7 @@ class DBSpendUpdateWriter:
|
|||
if call_type:
|
||||
endpoint = ROUTE_ENDPOINT_MAPPING.get(call_type, None)
|
||||
|
||||
is_internal_call: Final = bool(_metadata.get("internal_call_origin"))
|
||||
cache_read_input_tokens: Final = extract_cache_read_tokens(usage_obj)
|
||||
compression_saved_tokens: Final = extract_compression_saved_tokens(_metadata)
|
||||
savings_spend: Final = compute_savings_spend(
|
||||
|
|
@ -1818,15 +1819,21 @@ class DBSpendUpdateWriter:
|
|||
prompt_tokens=payload["prompt_tokens"],
|
||||
completion_tokens=payload["completion_tokens"],
|
||||
spend=payload["spend"],
|
||||
api_requests=1,
|
||||
successful_requests=1 if request_status == "success" else 0,
|
||||
failed_requests=1 if request_status != "success" else 0,
|
||||
# Internal sub-calls (auto-router classifier, shadow eval's shadow and
|
||||
# judge) bill real spend and tokens to the key, but they are not
|
||||
# requests the caller made: counting them inflates request-volume
|
||||
# readers (usage dashboards, the shadow-eval cost estimate), and an
|
||||
# auto-router savings figure computed on a shadow duplicate credits
|
||||
# savings for traffic no user sent.
|
||||
api_requests=0 if is_internal_call else 1,
|
||||
successful_requests=1 if not is_internal_call and request_status == "success" else 0,
|
||||
failed_requests=1 if not is_internal_call and request_status != "success" else 0,
|
||||
cache_read_input_tokens=cache_read_input_tokens,
|
||||
cache_creation_input_tokens=extract_cache_creation_tokens(usage_obj),
|
||||
compression_saved_tokens=compression_saved_tokens,
|
||||
compression_savings_spend=savings_spend.compression,
|
||||
prompt_caching_savings_spend=savings_spend.prompt_caching,
|
||||
autorouter_savings_spend=savings_spend.autorouter,
|
||||
autorouter_savings_spend=0.0 if is_internal_call else savings_spend.autorouter,
|
||||
)
|
||||
return daily_transaction
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Annotated, Final
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.exceptions import BudgetExceededError
|
||||
from litellm.integrations.shadow_eval_logger import JUDGE_MAX_OUTPUT_TOKENS
|
||||
|
|
@ -524,19 +525,61 @@ def _is_configured_pre_routing_strategy(llm_router: "Router", router_name: str)
|
|||
)
|
||||
|
||||
|
||||
def _estimate_judge_cost_per_call(judge_model: str) -> float:
|
||||
"""Price one judge call: ~4k prompt tokens (two responses + conversation) + the judge's output budget."""
|
||||
try:
|
||||
import litellm as _litellm
|
||||
def _judge_model_is_router_resolvable(llm_router: "Router | None", judge_model: str) -> bool:
|
||||
"""Whether the judge name resolves through the proxy's router, the same check the
|
||||
judge dispatch itself makes (and llm_as_a_judge before it), so validation cannot
|
||||
accept a name the call path then fails on."""
|
||||
return llm_router is not None and bool(
|
||||
judge_model in llm_router.model_group_alias or llm_router.get_model_list(model_name=judge_model)
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = _litellm.cost_per_token(
|
||||
model=judge_model, prompt_tokens=_JUDGE_PROMPT_TOKENS_ESTIMATE, completion_tokens=JUDGE_MAX_OUTPUT_TOKENS
|
||||
|
||||
def _validate_judge_model(llm_router: "Router | None", judge_model: str) -> None:
|
||||
"""Reject a judge model the dispatch path cannot resolve, at start rather than as a
|
||||
silently growing failed_count once the job is already sampling and billing."""
|
||||
if llm_router is not None and _is_configured_pre_routing_strategy(llm_router, judge_model):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"judge_model '{judge_model}' is an auto-router; the judge must be a plain model",
|
||||
)
|
||||
if _judge_model_is_router_resolvable(llm_router, judge_model):
|
||||
return
|
||||
try:
|
||||
litellm.get_llm_provider(model=judge_model)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"judge_model '{judge_model}' is neither a model configured on this proxy nor a "
|
||||
"provider-qualified public model name (e.g. 'anthropic/claude-sonnet-5')"
|
||||
),
|
||||
) from e
|
||||
|
||||
|
||||
def _judge_pricing_model(llm_router: "Router | None", judge_model: str) -> str:
|
||||
"""The name to price the judge under: a configured deployment's underlying
|
||||
provider model when the judge is a deployment (the deployment name itself is
|
||||
admin-arbitrary and not a pricing key), the given name otherwise."""
|
||||
deployments: Final = llm_router.get_model_list(model_name=judge_model) if llm_router is not None else None
|
||||
if deployments:
|
||||
underlying: Final = deployments[0].get("litellm_params", {}).get("model")
|
||||
if isinstance(underlying, str) and underlying:
|
||||
return underlying
|
||||
return judge_model
|
||||
|
||||
|
||||
def _estimate_judge_cost_per_call(llm_router: "Router | None", judge_model: str) -> float:
|
||||
"""Price one judge call: ~4k prompt tokens (two responses + conversation) + the judge's output budget."""
|
||||
pricing_model: Final = _judge_pricing_model(llm_router, judge_model)
|
||||
try:
|
||||
prompt_cost, completion_cost = litellm.cost_per_token(
|
||||
model=pricing_model, prompt_tokens=_JUDGE_PROMPT_TOKENS_ESTIMATE, completion_tokens=JUDGE_MAX_OUTPUT_TOKENS
|
||||
)
|
||||
estimated: Final = prompt_cost + completion_cost
|
||||
if estimated > 0:
|
||||
return estimated
|
||||
except Exception as e: # noqa: BLE001 # unknown judge model: fall back to a flat per-call figure
|
||||
verbose_proxy_logger.debug("shadow_eval: judge cost lookup failed for %s: %s", judge_model, e)
|
||||
verbose_proxy_logger.debug("shadow_eval: judge cost lookup failed for %s: %s", pricing_model, e)
|
||||
return _FALLBACK_JUDGE_COST_PER_CALL
|
||||
|
||||
|
||||
|
|
@ -652,6 +695,7 @@ class _ShadowEvalJobRow(BaseModel):
|
|||
request_count: int
|
||||
completed_count: int
|
||||
failed_count: int
|
||||
last_error: str | None = None
|
||||
cost_estimate: float | None = None
|
||||
cost_actual: float = 0.0
|
||||
created_at: datetime
|
||||
|
|
@ -674,6 +718,7 @@ def _job_to_response(record: object, results: ShadowEvalResult | None) -> GetSha
|
|||
request_count=row.request_count,
|
||||
completed_count=row.completed_count,
|
||||
failed_count=row.failed_count,
|
||||
last_error=row.last_error,
|
||||
results=results,
|
||||
cost_estimate=row.cost_estimate,
|
||||
cost_actual=row.cost_actual,
|
||||
|
|
@ -715,6 +760,7 @@ async def start_shadow_eval(
|
|||
status_code=400,
|
||||
detail=f"'{data.router_name}' is not a configured auto-router",
|
||||
)
|
||||
_validate_judge_model(llm_router, data.judge_model)
|
||||
|
||||
existing: Final = await prisma_client.db.litellm_shadowevaljob.find_first(
|
||||
where={ # mutable-ok: Prisma filter
|
||||
|
|
@ -732,7 +778,7 @@ async def start_shadow_eval(
|
|||
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)
|
||||
per_call: Final = _estimate_judge_cost_per_call(llm_router, data.judge_model)
|
||||
estimated_cost: Final = round(sampled * per_call, 2)
|
||||
ends_at: Final = datetime.now(timezone.utc) + timedelta(days=data.duration_days)
|
||||
|
||||
|
|
@ -829,7 +875,7 @@ async def stop_shadow_eval_job(
|
|||
job_id: str,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> GetShadowEvalJobResponse:
|
||||
"""Stop an active shadow eval job. Existing verdicts are kept; sampling halts within ~30s."""
|
||||
"""Stop an active shadow eval job. Existing verdicts are kept; sampling halts within ~10s."""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
_require_admin_writer(user_api_key_dict, "stop a shadow eval")
|
||||
|
|
|
|||
|
|
@ -1184,6 +1184,8 @@ async def proxy_startup_event(app: FastAPI):
|
|||
_tagged.strategy._state_loaded = True
|
||||
asyncio.create_task(_adaptive_router_flusher_loop())
|
||||
|
||||
_start_shadow_eval_lifecycle_loop()
|
||||
|
||||
## [Optional] Initialize dd tracer
|
||||
ProxyStartupEvent._init_dd_tracer()
|
||||
|
||||
|
|
@ -2219,13 +2221,29 @@ def _register_shadow_eval_logger() -> None:
|
|||
|
||||
Cheap when idle: with no active LiteLLM_ShadowEvalJob rows the hook is one
|
||||
cached dict lookup per request. Registered alongside cost tracking because
|
||||
it has the same hard dependency on prisma_client.
|
||||
it has the same hard dependency on prisma_client. Its lifecycle loop is
|
||||
started separately at proxy startup, once an event loop exists.
|
||||
"""
|
||||
from litellm.integrations.shadow_eval_logger import ShadowEvalLogger
|
||||
|
||||
litellm.logging_callback_manager.add_litellm_callback(ShadowEvalLogger())
|
||||
|
||||
|
||||
def _start_shadow_eval_lifecycle_loop() -> None:
|
||||
"""Start the registered shadow-eval logger's periodic lifecycle loop.
|
||||
|
||||
The loop owns counter flushes, snapshot refreshes, and finishing jobs whose
|
||||
window or spend cap has passed, so a job on a key that goes quiet still ends
|
||||
on schedule with its final counters written.
|
||||
"""
|
||||
from litellm.integrations.shadow_eval_logger import ShadowEvalLogger
|
||||
|
||||
for callback in litellm.callbacks:
|
||||
if isinstance(callback, ShadowEvalLogger):
|
||||
callback.start_lifecycle_loop()
|
||||
return
|
||||
|
||||
|
||||
# Bounds authoritative DB re-reads when enforcing a budget against a
|
||||
# stale-low spend counter: at most one DB read per counter per window.
|
||||
SPEND_DB_FLOOR_CACHE_TTL_SECONDS: Final = 5
|
||||
|
|
|
|||
|
|
@ -1466,10 +1466,7 @@ model LiteLLM_ShadowEvalJob {
|
|||
request_count Int @default(0) // total requests on the shadowed key during the job
|
||||
completed_count Int @default(0) // verdicts written
|
||||
failed_count Int @default(0) // shadow calls or judge calls that failed
|
||||
|
||||
// Results (written incrementally or at end)
|
||||
// { groups: [ { tier: "SIMPLE", turn_count: N, real_win_rate_pct: X, shadow_win_rate_pct: Y, tie_rate_pct: Z, avg_confidence: C } ] }
|
||||
result_json Json?
|
||||
last_error String? // most recent shadow/judge failure, for diagnosing a growing failed_count
|
||||
|
||||
cost_estimate Float? // estimated cost of running the judge
|
||||
cost_actual Float @default(0) // actual cost (judge calls * price)
|
||||
|
|
|
|||
|
|
@ -250,6 +250,9 @@ class GetShadowEvalJobResponse(BaseModel):
|
|||
request_count: int = Field(description="Total requests observed on the shadowed key since the job started")
|
||||
completed_count: int = Field(description="Verdicts written so far")
|
||||
failed_count: int = Field(description="Shadow or judge calls that errored and were skipped")
|
||||
last_error: str | None = Field(
|
||||
default=None, description="The most recent shadow or judge failure, so a growing failed_count is diagnosable"
|
||||
)
|
||||
results: ShadowEvalResult | None = Field(
|
||||
default=None, description="Present once at least one verdict has been recorded"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1466,10 +1466,7 @@ model LiteLLM_ShadowEvalJob {
|
|||
request_count Int @default(0) // total requests on the shadowed key during the job
|
||||
completed_count Int @default(0) // verdicts written
|
||||
failed_count Int @default(0) // shadow calls or judge calls that failed
|
||||
|
||||
// Results (written incrementally or at end)
|
||||
// { groups: [ { tier: "SIMPLE", turn_count: N, real_win_rate_pct: X, shadow_win_rate_pct: Y, tie_rate_pct: Z, avg_confidence: C } ] }
|
||||
result_json Json?
|
||||
last_error String? // most recent shadow/judge failure, for diagnosing a growing failed_count
|
||||
|
||||
cost_estimate Float? // estimated cost of running the judge
|
||||
cost_actual Float @default(0) // actual cost (judge calls * price)
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import pytest
|
|||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.integrations.shadow_eval_logger import (
|
||||
JUDGE_MAX_OUTPUT_TOKENS,
|
||||
SHADOW_EVAL_INTERNAL_MARKER,
|
||||
ActiveShadowEvalJob,
|
||||
ShadowEvalLogger,
|
||||
_CallFailure,
|
||||
_JudgeVerdict,
|
||||
_key_or_team_is_over_budget,
|
||||
_parse_pairwise_verdict,
|
||||
_sample_hits,
|
||||
_ShadowResponse,
|
||||
_unmask_preference,
|
||||
)
|
||||
from litellm.types.utils import SHADOW_EVAL_JUDGE_CALL_ORIGIN, SHADOW_EVAL_ROUTER_CALL_ORIGIN
|
||||
|
|
@ -99,12 +101,12 @@ class TestJudgeOutputBudget:
|
|||
)
|
||||
monkeypatch.setattr(litellm_module, "acompletion", acompletion)
|
||||
|
||||
logger = ShadowEvalLogger(router_provider=lambda: MagicMock(), prisma_provider=lambda: MagicMock())
|
||||
logger = ShadowEvalLogger(router_provider=_router_mock, prisma_provider=lambda: MagicMock())
|
||||
verdict = await logger._call_judge(
|
||||
"gpt-4o-mini", [{"role": "user", "content": "hi"}], "real text", "shadow text", {}
|
||||
)
|
||||
|
||||
assert verdict is not None
|
||||
assert not isinstance(verdict, _CallFailure)
|
||||
_, kwargs = acompletion.call_args
|
||||
assert kwargs["max_tokens"] == JUDGE_MAX_OUTPUT_TOKENS
|
||||
|
||||
|
|
@ -115,13 +117,36 @@ class TestJudgeOutputBudgetStaticChecks:
|
|||
assert JUDGE_MAX_OUTPUT_TOKENS >= 500
|
||||
|
||||
|
||||
def _job_record(job: ActiveShadowEvalJob, api_key_id: str = "key-hash") -> MagicMock:
|
||||
"""A Prisma row double mirroring an ActiveShadowEvalJob, for snapshot refreshes."""
|
||||
record = MagicMock()
|
||||
record.id = job.id
|
||||
record.api_key_id = api_key_id
|
||||
record.router_name = job.router_name
|
||||
record.shadow_percentage = job.shadow_percentage
|
||||
record.judge_model = job.judge_model
|
||||
record.status = job.status
|
||||
record.cost_estimate = job.cost_estimate
|
||||
record.cost_actual = job.cost_actual
|
||||
record.ends_at = job.ends_at
|
||||
return record
|
||||
|
||||
|
||||
def _router_mock(resolves_judge: bool = False):
|
||||
"""A router double that, by default, does not resolve judge names, so judge
|
||||
dispatch falls through to the SDK exactly as it did for public model names."""
|
||||
router = MagicMock()
|
||||
router.model_group_alias = {}
|
||||
router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o"}}] if resolves_judge else None)
|
||||
return router
|
||||
|
||||
|
||||
def _logger_with_mocks(job=None):
|
||||
prisma = MagicMock()
|
||||
router = MagicMock()
|
||||
router = _router_mock()
|
||||
logger = ShadowEvalLogger(router_provider=lambda: router, prisma_provider=lambda: prisma)
|
||||
if job is not None:
|
||||
logger._jobs_by_key["key-hash"] = job
|
||||
logger._jobs_fetched_at = asyncio.get_event_loop().time()
|
||||
return logger, prisma, router
|
||||
|
||||
|
||||
|
|
@ -132,7 +157,16 @@ class TestSuccessHookSkipPaths:
|
|||
await logger.async_log_success_event({"messages": []}, MagicMock(), None, None)
|
||||
prisma.db.litellm_shadowevaljob.find_many.assert_not_called()
|
||||
|
||||
async def test_skips_own_internal_traffic(self):
|
||||
@pytest.mark.parametrize(
|
||||
"origin",
|
||||
[SHADOW_EVAL_ROUTER_CALL_ORIGIN, SHADOW_EVAL_JUDGE_CALL_ORIGIN, "autorouter_classifier"],
|
||||
)
|
||||
async def test_skips_any_internal_sub_call(self, origin):
|
||||
"""Regression: the auto-router's own classifier calls are logged as ordinary
|
||||
successes on the shadowed key; sampling them judges a tier label against a
|
||||
shadow answer and burns judge spend on traffic no user sent. One stamp,
|
||||
internal_call_origin, classifies every internal sub-call, including this
|
||||
logger's own shadow/judge traffic (the recursion guard)."""
|
||||
job = ActiveShadowEvalJob(id="j1", router_name="r", shadow_percentage=100.0, judge_model="m", status="running")
|
||||
logger, prisma, _ = _logger_with_mocks(job)
|
||||
kwargs = {
|
||||
|
|
@ -141,7 +175,7 @@ class TestSuccessHookSkipPaths:
|
|||
"model": "gpt-4o",
|
||||
"metadata": {"user_api_key_hash": "key-hash"},
|
||||
},
|
||||
"litellm_params": {"metadata": {SHADOW_EVAL_INTERNAL_MARKER: True}},
|
||||
"litellm_params": {"metadata": {INTERNAL_CALL_ORIGIN_METADATA_KEY: origin}},
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
await logger.async_log_success_event(kwargs, MagicMock(), None, None)
|
||||
|
|
@ -224,7 +258,7 @@ class TestSuccessHookSkipPaths:
|
|||
@pytest.mark.asyncio
|
||||
class TestCallRouterShadowForwardsParameters:
|
||||
async def test_forwards_non_default_params(self):
|
||||
router = MagicMock()
|
||||
router = _router_mock()
|
||||
router.acompletion = AsyncMock(return_value={"choices": [{"message": {"content": "shadow reply"}}]})
|
||||
logger = ShadowEvalLogger(router_provider=lambda: router, prisma_provider=lambda: MagicMock())
|
||||
|
||||
|
|
@ -241,7 +275,7 @@ class TestCallRouterShadowForwardsParameters:
|
|||
assert kwargs["max_tokens"] == 500
|
||||
|
||||
async def test_drops_stream_and_metadata_from_forwarded_params(self):
|
||||
router = MagicMock()
|
||||
router = _router_mock()
|
||||
router.acompletion = AsyncMock(return_value={"choices": [{"message": {"content": "shadow reply"}}]})
|
||||
logger = ShadowEvalLogger(router_provider=lambda: router, prisma_provider=lambda: MagicMock())
|
||||
|
||||
|
|
@ -258,7 +292,7 @@ class TestCallRouterShadowForwardsParameters:
|
|||
# model_parameters carries a stale copy of the parent's metadata; the shadow call
|
||||
# builds its own from the parent metadata argument instead.
|
||||
assert "leaked" not in str(kwargs["metadata"])
|
||||
assert kwargs["metadata"][SHADOW_EVAL_INTERNAL_MARKER] is True
|
||||
assert kwargs["metadata"][INTERNAL_CALL_ORIGIN_METADATA_KEY] == SHADOW_EVAL_ROUTER_CALL_ORIGIN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -327,8 +361,12 @@ class TestStoppedJobCannotBeReactivated:
|
|||
del prisma.db.litellm_shadowevaljob.update
|
||||
|
||||
logger = ShadowEvalLogger(router_provider=lambda: MagicMock(), prisma_provider=lambda: prisma)
|
||||
logger._call_router_shadow = AsyncMock(return_value=("shadow text", "shadow-model", "SIMPLE", 10))
|
||||
logger._call_judge = AsyncMock(return_value=("real", 0.9, "clearer", 0.01))
|
||||
logger._call_router_shadow = AsyncMock(
|
||||
return_value=_ShadowResponse(text="shadow text", model="shadow-model", tier="SIMPLE", completion_tokens=10)
|
||||
)
|
||||
logger._call_judge = AsyncMock(
|
||||
return_value=_JudgeVerdict(preference="real", confidence=0.9, reasoning="clearer", cost=0.01)
|
||||
)
|
||||
|
||||
job = ActiveShadowEvalJob(id="j1", router_name="r", judge_model="m", shadow_percentage=100.0, status="running")
|
||||
await logger._run_shadow_eval(
|
||||
|
|
@ -364,8 +402,12 @@ class TestVerdictWriteAccumulatesCost:
|
|||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
|
||||
logger = ShadowEvalLogger(router_provider=lambda: MagicMock(), prisma_provider=lambda: prisma)
|
||||
logger._call_router_shadow = AsyncMock(return_value=("shadow text", "shadow-model", "SIMPLE", 10))
|
||||
logger._call_judge = AsyncMock(return_value=("real", 0.9, "clearer", 0.05))
|
||||
logger._call_router_shadow = AsyncMock(
|
||||
return_value=_ShadowResponse(text="shadow text", model="shadow-model", tier="SIMPLE", completion_tokens=10)
|
||||
)
|
||||
logger._call_judge = AsyncMock(
|
||||
return_value=_JudgeVerdict(preference="real", confidence=0.9, reasoning="clearer", cost=0.05)
|
||||
)
|
||||
|
||||
job = ActiveShadowEvalJob(id="j1", router_name="r", judge_model="m", shadow_percentage=100.0, status="running")
|
||||
await logger._run_shadow_eval(
|
||||
|
|
@ -439,19 +481,18 @@ class TestSubCallsAreAttributedToTheShadowedKey:
|
|||
}
|
||||
)
|
||||
monkeypatch.setattr(litellm_module, "acompletion", acompletion)
|
||||
logger = ShadowEvalLogger(router_provider=lambda: MagicMock(), prisma_provider=lambda: MagicMock())
|
||||
logger = ShadowEvalLogger(router_provider=_router_mock, prisma_provider=lambda: MagicMock())
|
||||
|
||||
verdict = await logger._call_judge(
|
||||
"gpt-4o-mini", [{"role": "user", "content": "hi"}], "real text", "shadow text", _PARENT_METADATA
|
||||
)
|
||||
|
||||
assert verdict is not None
|
||||
assert not isinstance(verdict, _CallFailure)
|
||||
metadata = acompletion.call_args.kwargs["metadata"]
|
||||
_assert_attributed(metadata, SHADOW_EVAL_JUDGE_CALL_ORIGIN)
|
||||
assert metadata[SHADOW_EVAL_INTERNAL_MARKER] is True
|
||||
|
||||
async def test_shadow_router_call_carries_the_key_identity_and_the_recursion_guard(self):
|
||||
router = MagicMock()
|
||||
router = _router_mock()
|
||||
router.acompletion = AsyncMock(return_value={"choices": [{"message": {"content": "shadow reply"}}]})
|
||||
logger = ShadowEvalLogger(router_provider=lambda: router, prisma_provider=lambda: MagicMock())
|
||||
|
||||
|
|
@ -459,15 +500,15 @@ class TestSubCallsAreAttributedToTheShadowedKey:
|
|||
"claude-auto", [{"role": "user", "content": "hi"}], {}, _PARENT_METADATA
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert not isinstance(result, _CallFailure)
|
||||
metadata = router.acompletion.call_args.kwargs["metadata"]
|
||||
# The origin stamp doubles as the recursion guard: the hook skips any
|
||||
# request carrying it, so the shadow call cannot shadow itself.
|
||||
_assert_attributed(metadata, SHADOW_EVAL_ROUTER_CALL_ORIGIN)
|
||||
# Without the marker the shadow call's own success event would shadow itself.
|
||||
assert metadata[SHADOW_EVAL_INTERNAL_MARKER] is True
|
||||
|
||||
async def test_shadow_metadata_stays_writable_for_the_routing_decision_read_back(self):
|
||||
"""Tier attribution reads routing_decision back out of the dict the router was given."""
|
||||
router = MagicMock()
|
||||
router = _router_mock()
|
||||
|
||||
async def acompletion(**kwargs):
|
||||
kwargs["metadata"]["routing_decision"] = {"tier_label": "COMPLEX", "routed_model": "o1"}
|
||||
|
|
@ -480,8 +521,8 @@ class TestSubCallsAreAttributedToTheShadowedKey:
|
|||
"claude-auto", [{"role": "user", "content": "hi"}], {}, _PARENT_METADATA
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result[2] == "COMPLEX"
|
||||
assert not isinstance(result, _CallFailure)
|
||||
assert result.tier == "COMPLEX"
|
||||
|
||||
async def test_parent_metadata_reaches_both_legs_from_the_success_hook(self):
|
||||
"""The hook is the only place the parent's metadata exists; a break here is invisible
|
||||
|
|
@ -492,8 +533,12 @@ class TestSubCallsAreAttributedToTheShadowedKey:
|
|||
job = ActiveShadowEvalJob(id="j1", router_name="r", shadow_percentage=100.0, judge_model="m", status="running")
|
||||
logger, _, _ = _logger_with_mocks(job)
|
||||
logger._prisma_provider = lambda: prisma
|
||||
logger._call_router_shadow = AsyncMock(return_value=("shadow text", "shadow-model", "SIMPLE", 10))
|
||||
logger._call_judge = AsyncMock(return_value=("real", 0.9, "clearer", 0.01))
|
||||
logger._call_router_shadow = AsyncMock(
|
||||
return_value=_ShadowResponse(text="shadow text", model="shadow-model", tier="SIMPLE", completion_tokens=10)
|
||||
)
|
||||
logger._call_judge = AsyncMock(
|
||||
return_value=_JudgeVerdict(preference="real", confidence=0.9, reasoning="clearer", cost=0.01)
|
||||
)
|
||||
|
||||
await logger.async_log_success_event(
|
||||
{
|
||||
|
|
@ -648,7 +693,7 @@ class TestPerJobSpendCap:
|
|||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
|
||||
async def test_job_over_the_cap_stops_sampling_and_completes_the_job(self):
|
||||
async def test_job_over_the_cap_stops_sampling(self):
|
||||
job = ActiveShadowEvalJob(
|
||||
id="j1",
|
||||
router_name="r",
|
||||
|
|
@ -659,7 +704,6 @@ class TestPerJobSpendCap:
|
|||
cost_actual=15.0,
|
||||
)
|
||||
logger, prisma, router = _logger_with_mocks(job)
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
logger._run_shadow_eval = AsyncMock()
|
||||
|
||||
await logger.async_log_success_event(self._success_kwargs(), MagicMock(), None, None)
|
||||
|
|
@ -667,7 +711,24 @@ class TestPerJobSpendCap:
|
|||
|
||||
logger._run_shadow_eval.assert_not_awaited()
|
||||
router.acompletion.assert_not_called()
|
||||
prisma.db.litellm_shadowevaljob.update_many.assert_awaited_once()
|
||||
|
||||
async def test_lifecycle_tick_completes_a_job_over_the_cap(self):
|
||||
"""The cap is enforced by the loop, so it lands even on a key gone quiet."""
|
||||
job = ActiveShadowEvalJob(
|
||||
id="j1",
|
||||
router_name="r",
|
||||
shadow_percentage=100.0,
|
||||
judge_model="m",
|
||||
status="running",
|
||||
cost_estimate=10.0,
|
||||
cost_actual=15.0,
|
||||
)
|
||||
logger, prisma, _ = _logger_with_mocks(job)
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[_job_record(job)])
|
||||
|
||||
await logger._lifecycle_tick()
|
||||
|
||||
call_kwargs = prisma.db.litellm_shadowevaljob.update_many.call_args.kwargs
|
||||
assert call_kwargs["where"]["id"] == "j1"
|
||||
# Guarded so a pod breaching the cap cannot resurrect a job an admin already stopped.
|
||||
|
|
@ -686,14 +747,12 @@ class TestPerJobSpendCap:
|
|||
cost_actual=14.99,
|
||||
)
|
||||
logger, prisma, _ = _logger_with_mocks(job)
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
logger._run_shadow_eval = AsyncMock()
|
||||
|
||||
await logger.async_log_success_event(self._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_estimate_is_uncapped(self):
|
||||
"""A missing estimate is no multiple to compare against; treating it as 0 would
|
||||
|
|
@ -708,14 +767,12 @@ class TestPerJobSpendCap:
|
|||
cost_actual=500.0,
|
||||
)
|
||||
logger, prisma, _ = _logger_with_mocks(job)
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
logger._run_shadow_eval = AsyncMock()
|
||||
|
||||
await logger.async_log_success_event(self._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_a_zero_estimate_job_is_still_capped_at_the_floor(self):
|
||||
"""A key quiet during the lookback gets estimate $0.00; a later traffic spike on
|
||||
|
|
@ -731,12 +788,14 @@ class TestPerJobSpendCap:
|
|||
)
|
||||
logger, prisma, _ = _logger_with_mocks(job)
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[_job_record(job)])
|
||||
logger._run_shadow_eval = AsyncMock()
|
||||
|
||||
await logger.async_log_success_event(self._success_kwargs(), MagicMock(), None, None)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
logger._run_shadow_eval.assert_not_awaited()
|
||||
|
||||
await logger._lifecycle_tick()
|
||||
assert prisma.db.litellm_shadowevaljob.update_many.call_args.kwargs["data"]["status"] == "completed"
|
||||
|
||||
async def test_a_cent_sized_estimate_is_not_stopped_by_its_first_verdict(self):
|
||||
|
|
@ -759,7 +818,7 @@ class TestPerJobSpendCap:
|
|||
|
||||
logger._run_shadow_eval.assert_awaited_once()
|
||||
|
||||
async def test_stopping_evicts_the_cached_job_so_later_requests_do_not_rewrite_it(self):
|
||||
async def test_finalizing_evicts_the_job_so_a_second_tick_does_not_rewrite_it(self):
|
||||
job = ActiveShadowEvalJob(
|
||||
id="j1",
|
||||
router_name="r",
|
||||
|
|
@ -771,104 +830,154 @@ class TestPerJobSpendCap:
|
|||
)
|
||||
logger, prisma, _ = _logger_with_mocks(job)
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[])
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(side_effect=[[_job_record(job)], []])
|
||||
|
||||
await logger.async_log_success_event(self._success_kwargs(), MagicMock(), None, None)
|
||||
await logger.async_log_success_event(self._success_kwargs(), MagicMock(), None, None)
|
||||
await logger._lifecycle_tick()
|
||||
await logger._lifecycle_tick()
|
||||
|
||||
assert prisma.db.litellm_shadowevaljob.update_many.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestActiveJobSnapshot:
|
||||
"""One find_many per pod per TTL serves every key, so DB load stays flat no
|
||||
"""One find_many per pod per tick serves every key, so DB load stays flat no
|
||||
matter how many distinct keys send traffic through the proxy."""
|
||||
|
||||
@staticmethod
|
||||
def _record(job_id: str, api_key_id: str) -> MagicMock:
|
||||
record = MagicMock()
|
||||
record.id = job_id
|
||||
record.api_key_id = api_key_id
|
||||
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 = None
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
async def _settled(logger):
|
||||
if logger._jobs_refresh_task is not None:
|
||||
await logger._jobs_refresh_task
|
||||
|
||||
async def test_lookup_never_awaits_the_db_and_one_query_serves_many_keys(self):
|
||||
async def test_hook_lookup_never_awaits_the_db(self):
|
||||
"""The success hook reads the loop-maintained snapshot; a cold pod answers
|
||||
from the empty snapshot instead of blocking on Prisma."""
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[self._record("j1", "key-1")])
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[])
|
||||
kwargs = {
|
||||
"standard_logging_object": {
|
||||
"id": "req-1",
|
||||
"model": "gpt-4o",
|
||||
"call_type": "acompletion",
|
||||
"metadata": {"user_api_key_hash": "key-1"},
|
||||
},
|
||||
"litellm_params": {"metadata": {}},
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
await logger.async_log_success_event(kwargs, MagicMock(), None, None)
|
||||
prisma.db.litellm_shadowevaljob.find_many.assert_not_awaited()
|
||||
|
||||
cold = logger._get_active_job("key-1")
|
||||
await self._settled(logger)
|
||||
warm = logger._get_active_job("key-1")
|
||||
misses = [logger._get_active_job(f"other-{i}") for i in range(50)]
|
||||
async def test_one_refresh_serves_many_keys(self):
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
job = ActiveShadowEvalJob(id="j1", router_name="r", shadow_percentage=100.0, judge_model="m", status="running")
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[_job_record(job, api_key_id="key-1")])
|
||||
|
||||
assert cold is None, "cold pod answers from the empty snapshot instead of blocking on Prisma"
|
||||
assert warm is not None and warm.id == "j1"
|
||||
assert all(m is None for m in misses)
|
||||
await logger._refresh_active_jobs()
|
||||
|
||||
assert logger._jobs_by_key["key-1"].id == "j1"
|
||||
assert all(logger._jobs_by_key.get(f"other-{i}") is None for i in range(50))
|
||||
assert prisma.db.litellm_shadowevaljob.find_many.await_count == 1
|
||||
|
||||
async def test_newest_job_wins_when_a_key_somehow_has_two(self):
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
newest = ActiveShadowEvalJob(
|
||||
id="j-newest", router_name="r", shadow_percentage=100.0, judge_model="m", status="running"
|
||||
)
|
||||
oldest = ActiveShadowEvalJob(
|
||||
id="j-oldest", router_name="r", shadow_percentage=100.0, judge_model="m", status="running"
|
||||
)
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(
|
||||
return_value=[self._record("j-newest", "key-1"), self._record("j-oldest", "key-1")]
|
||||
return_value=[_job_record(newest, "key-1"), _job_record(oldest, "key-1")]
|
||||
)
|
||||
|
||||
logger._get_active_job("key-1")
|
||||
await self._settled(logger)
|
||||
job = logger._get_active_job("key-1")
|
||||
await logger._refresh_active_jobs()
|
||||
|
||||
assert job is not None and job.id == "j-newest"
|
||||
assert logger._jobs_by_key["key-1"].id == "j-newest"
|
||||
|
||||
async def test_db_blip_keeps_the_stale_snapshot_instead_of_disabling_the_feature(self):
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[self._record("j1", "key-1")])
|
||||
logger._get_active_job("key-1")
|
||||
await self._settled(logger)
|
||||
job = ActiveShadowEvalJob(id="j1", router_name="r", shadow_percentage=100.0, judge_model="m", status="running")
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[_job_record(job, "key-1")])
|
||||
await logger._refresh_active_jobs()
|
||||
|
||||
logger._jobs_fetched_at = asyncio.get_event_loop().time() - 61.0
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
await logger._refresh_active_jobs()
|
||||
|
||||
stale = logger._get_active_job("key-1")
|
||||
await self._settled(logger)
|
||||
assert logger._jobs_by_key["key-1"].id == "j1"
|
||||
|
||||
assert stale is not None and stale.id == "j1"
|
||||
assert logger._get_active_job("key-1") is not None
|
||||
|
||||
async def test_a_slow_refresh_is_not_stacked_by_concurrent_lookups(self):
|
||||
async def test_a_failing_tick_does_not_kill_the_loop(self):
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
logger._pending_seen = {"j1": 3}
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
|
||||
async def slow_find_many(**_kwargs):
|
||||
started.set()
|
||||
await release.wait()
|
||||
return [self._record("j1", "key-1")]
|
||||
await logger._lifecycle_tick()
|
||||
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(side_effect=slow_find_many)
|
||||
async def test_start_lifecycle_loop_is_idempotent(self):
|
||||
logger, _, _ = _logger_with_mocks()
|
||||
logger.start_lifecycle_loop()
|
||||
first = logger._lifecycle_task
|
||||
logger.start_lifecycle_loop()
|
||||
assert logger._lifecycle_task is first
|
||||
first.cancel()
|
||||
|
||||
for _ in range(10):
|
||||
logger._get_active_job("key-1")
|
||||
await started.wait()
|
||||
release.set()
|
||||
await self._settled(logger)
|
||||
|
||||
assert prisma.db.litellm_shadowevaljob.find_many.await_count == 1
|
||||
@pytest.mark.asyncio
|
||||
class TestRequestCountFlush:
|
||||
"""request_count is flushed by the loop, not by the next request arriving, so
|
||||
the final batch lands and a stopped job's counter freezes."""
|
||||
|
||||
async def test_tick_flushes_buffered_counts_without_new_requests(self):
|
||||
"""Regression: the old flush was piggybacked on a later request 10s+ after
|
||||
the last flush, so the final batch of a job was never written and idle jobs
|
||||
read 'N judged of 0 seen'."""
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[])
|
||||
logger._pending_seen = {"j1": 7}
|
||||
|
||||
await logger._lifecycle_tick()
|
||||
|
||||
flush_call = prisma.db.litellm_shadowevaljob.update_many.call_args_list[0]
|
||||
assert flush_call.kwargs["where"]["id"] == "j1"
|
||||
assert flush_call.kwargs["data"] == {"request_count": {"increment": 7}}
|
||||
assert logger._pending_seen == {}
|
||||
|
||||
async def test_flush_is_guarded_on_the_job_still_being_active(self):
|
||||
"""Stopping a job freezes its request_count: a pod serving a stale snapshot
|
||||
keeps buffering for up to one tick, and the status-guarded write drops those
|
||||
increments instead of growing a stopped job's counter for ~30s after stop."""
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
logger._pending_seen = {"j1": 2}
|
||||
|
||||
await logger._flush_seen_counts()
|
||||
|
||||
where = prisma.db.litellm_shadowevaljob.update_many.call_args.kwargs["where"]
|
||||
assert set(where["status"]["in"]) == {"pending", "running"}
|
||||
|
||||
async def test_flush_before_finalize_lands_the_tail_batch_of_an_expiring_job(self):
|
||||
"""The tick flushes first, so an expiring job's last counts pass the active
|
||||
guard before the same tick flips it to completed."""
|
||||
expired = ActiveShadowEvalJob(
|
||||
id="j1",
|
||||
router_name="r",
|
||||
shadow_percentage=100.0,
|
||||
judge_model="m",
|
||||
status="running",
|
||||
ends_at=datetime.now(timezone.utc) - timedelta(seconds=1),
|
||||
)
|
||||
logger, prisma, _ = _logger_with_mocks(expired)
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[_job_record(expired)])
|
||||
logger._pending_seen = {"j1": 4}
|
||||
|
||||
await logger._lifecycle_tick()
|
||||
|
||||
calls = prisma.db.litellm_shadowevaljob.update_many.call_args_list
|
||||
assert calls[0].kwargs["data"] == {"request_count": {"increment": 4}}
|
||||
assert calls[-1].kwargs["data"]["status"] == "completed"
|
||||
|
||||
|
||||
@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."""
|
||||
stop billing judge calls even if nobody remembers to stop it by hand, and even
|
||||
if its key never sends another request."""
|
||||
|
||||
@staticmethod
|
||||
def _job(ends_at):
|
||||
|
|
@ -883,9 +992,8 @@ class TestJobsStopAtTheirScheduledEnd:
|
|||
ends_at=ends_at,
|
||||
)
|
||||
|
||||
async def test_job_past_its_end_stops_sampling_and_completes(self):
|
||||
async def test_job_past_its_end_stops_sampling(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)
|
||||
|
|
@ -893,6 +1001,17 @@ class TestJobsStopAtTheirScheduledEnd:
|
|||
|
||||
logger._run_shadow_eval.assert_not_awaited()
|
||||
router.acompletion.assert_not_called()
|
||||
|
||||
async def test_lifecycle_tick_completes_an_expired_job_with_no_traffic_at_all(self):
|
||||
"""Regression: expiry used to be checked only inside the success hook, so a
|
||||
job on a key gone quiet stayed pending/running indefinitely."""
|
||||
expired = self._job(datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
logger, prisma, _ = _logger_with_mocks()
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[_job_record(expired)])
|
||||
|
||||
await logger._lifecycle_tick()
|
||||
|
||||
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"}
|
||||
|
|
@ -919,36 +1038,14 @@ class TestJobsStopAtTheirScheduledEnd:
|
|||
|
||||
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_many = AsyncMock(return_value=[])
|
||||
|
||||
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.api_key_id = "key-hash"
|
||||
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_many = AsyncMock(return_value=[record])
|
||||
naive_expired = self._job(datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=1))
|
||||
prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[_job_record(naive_expired)])
|
||||
|
||||
logger._get_active_job("key-hash")
|
||||
if logger._jobs_refresh_task is not None:
|
||||
await logger._jobs_refresh_task
|
||||
job = logger._get_active_job("key-hash")
|
||||
await logger._refresh_active_jobs()
|
||||
job = logger._jobs_by_key["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
|
||||
|
||||
|
|
@ -963,22 +1060,24 @@ class TestJudgeFailureModes:
|
|||
@staticmethod
|
||||
def _logger():
|
||||
prisma = MagicMock()
|
||||
return ShadowEvalLogger(router_provider=lambda: MagicMock(), prisma_provider=lambda: prisma), prisma
|
||||
router = _router_mock()
|
||||
return ShadowEvalLogger(router_provider=lambda: router, prisma_provider=lambda: prisma), prisma, router
|
||||
|
||||
async def test_judge_provider_error_returns_none(self, monkeypatch: pytest.MonkeyPatch):
|
||||
async def test_judge_provider_error_is_a_described_failure(self, monkeypatch: pytest.MonkeyPatch):
|
||||
import litellm as litellm_module
|
||||
|
||||
logger, _ = self._logger()
|
||||
logger, _, _ = self._logger()
|
||||
monkeypatch.setattr(litellm_module, "acompletion", AsyncMock(side_effect=RuntimeError("provider down")))
|
||||
|
||||
verdict = await logger._call_judge("m", [{"role": "user", "content": "hi"}], "real", "shadow", {})
|
||||
|
||||
assert verdict is None
|
||||
assert isinstance(verdict, _CallFailure)
|
||||
assert "provider down" in verdict.error
|
||||
|
||||
async def test_unparseable_judge_output_returns_none(self, monkeypatch: pytest.MonkeyPatch):
|
||||
async def test_unparseable_judge_output_is_a_described_failure(self, monkeypatch: pytest.MonkeyPatch):
|
||||
import litellm as litellm_module
|
||||
|
||||
logger, _ = self._logger()
|
||||
logger, _, _ = self._logger()
|
||||
monkeypatch.setattr(
|
||||
litellm_module,
|
||||
"acompletion",
|
||||
|
|
@ -987,16 +1086,21 @@ class TestJudgeFailureModes:
|
|||
|
||||
verdict = await logger._call_judge("m", [{"role": "user", "content": "hi"}], "real", "shadow", {})
|
||||
|
||||
assert verdict is None
|
||||
assert isinstance(verdict, _CallFailure)
|
||||
assert "unparseable" in verdict.error
|
||||
|
||||
async def test_failed_judge_bumps_failed_count_and_writes_no_verdict(self, monkeypatch: pytest.MonkeyPatch):
|
||||
async def test_failed_judge_bumps_failed_count_with_last_error_and_writes_no_verdict(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Regression: a misconfigured judge model failed every turn with nothing but
|
||||
a debug log, so the admin saw only a growing failed_count and null results.
|
||||
The most recent failure is persisted on the job for the UI to show."""
|
||||
import litellm as litellm_module
|
||||
|
||||
logger, prisma = self._logger()
|
||||
logger, prisma, router = self._logger()
|
||||
monkeypatch.setattr(litellm_module, "acompletion", AsyncMock(side_effect=RuntimeError("down")))
|
||||
router = logger._router_provider()
|
||||
router.acompletion = AsyncMock(return_value={"choices": [{"message": {"content": "shadow says"}}]})
|
||||
prisma.db.litellm_shadowevaljob.update = AsyncMock()
|
||||
prisma.db.litellm_shadowevaljob.update_many = AsyncMock()
|
||||
job = ActiveShadowEvalJob(id="j1", router_name="r", shadow_percentage=100.0, judge_model="m", status="running")
|
||||
|
||||
await logger._run_shadow_eval(
|
||||
|
|
@ -1010,7 +1114,30 @@ class TestJudgeFailureModes:
|
|||
)
|
||||
|
||||
prisma.db.litellm_shadowevalverdict.create.assert_not_called()
|
||||
assert prisma.db.litellm_shadowevaljob.update.call_args.kwargs["data"] == {"failed_count": {"increment": 1}}
|
||||
call_kwargs = prisma.db.litellm_shadowevaljob.update_many.call_args.kwargs
|
||||
assert call_kwargs["data"]["failed_count"] == {"increment": 1}
|
||||
assert "down" in call_kwargs["data"]["last_error"]
|
||||
assert set(call_kwargs["where"]["status"]["in"]) == {"pending", "running"}
|
||||
|
||||
async def test_judge_configured_as_a_proxy_deployment_dispatches_through_the_router(self):
|
||||
"""Regression: the judge always went through the SDK, so a judge named after a
|
||||
proxy deployment failed every turn with 'LLM Provider NOT provided' while
|
||||
still paying for the shadow call."""
|
||||
logger, _, router = self._logger()
|
||||
router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o"}}])
|
||||
router.acompletion = AsyncMock(
|
||||
return_value={
|
||||
"choices": [{"message": {"content": '{"preference": "A", "confidence": 0.8, "reasoning": "x"}'}}]
|
||||
}
|
||||
)
|
||||
|
||||
verdict = await logger._call_judge(
|
||||
"my-deployment", [{"role": "user", "content": "hi"}], "real", "shadow", {}
|
||||
)
|
||||
|
||||
assert not isinstance(verdict, _CallFailure)
|
||||
router.acompletion.assert_awaited_once()
|
||||
assert router.acompletion.call_args.kwargs["model"] == "my-deployment"
|
||||
|
||||
|
||||
class TestExtractResponseText:
|
||||
|
|
|
|||
|
|
@ -93,6 +93,15 @@ class TestBuildTransaction:
|
|||
def test_requests_without_a_routing_decision_are_skipped(self, metadata: dict):
|
||||
assert _build(metadata=metadata) is None
|
||||
|
||||
@pytest.mark.parametrize("origin", ["shadow_eval_router", "shadow_eval_judge", "autorouter_classifier"])
|
||||
def test_internal_sub_calls_are_excluded_even_with_a_routing_decision(self, origin: str):
|
||||
"""Regression: a shadow eval duplicates requests through the router, so its
|
||||
sub-calls carry a genuine routing_decision and rolled up as real routed
|
||||
sessions, inflating session counts and saved_spend in the benchmarks a
|
||||
pre-adoption eval renders next to. Traffic no user sent must never
|
||||
become adoption metrics."""
|
||||
assert _build(metadata=_metadata(internal_call_origin=origin)) is None
|
||||
|
||||
def test_the_tier_the_decision_recorded_is_carried_onto_the_transaction(self):
|
||||
transaction = _build(metadata=_metadata(routing_decision={**ROUTING_DECISION, "tier": "reasoning"}))
|
||||
assert transaction is not None and transaction.tier == "reasoning"
|
||||
|
|
|
|||
|
|
@ -2157,3 +2157,70 @@ async def test_daily_transaction_compression_saved_tokens_zero_when_absent():
|
|||
assert transaction["compression_saved_tokens"] == 0
|
||||
assert transaction["compression_savings_spend"] == 0
|
||||
assert transaction["prompt_caching_savings_spend"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("origin", ["shadow_eval_router", "shadow_eval_judge", "autorouter_classifier"])
|
||||
async def test_internal_sub_calls_keep_spend_but_are_not_counted_as_requests(origin: str):
|
||||
"""Regression: shadow-eval and classifier sub-calls counted toward api_requests, so
|
||||
the shadow-eval cost estimate (SUM(api_requests) over LiteLLM_DailyUserSpend) fed on
|
||||
its own output and usage dashboards inflated. Spend and tokens must still land so
|
||||
budgets and billing stay whole; the derived auto-router savings of a shadow
|
||||
duplicate is not a saving anyone realized."""
|
||||
db_writer = DBSpendUpdateWriter()
|
||||
payload = {
|
||||
"user": "user-1",
|
||||
"api_key": "hashed-key",
|
||||
"model": "gpt-4o",
|
||||
"model_group": "gpt-4o",
|
||||
"custom_llm_provider": "openai",
|
||||
"startTime": datetime(2026, 8, 10, 12, 0, tzinfo=timezone.utc),
|
||||
"spend": 0.05,
|
||||
"prompt_tokens": 90,
|
||||
"completion_tokens": 10,
|
||||
"metadata": json.dumps(
|
||||
{
|
||||
"internal_call_origin": origin,
|
||||
"routing_decision": {"router_model_name": "claude-auto", "router_type": "complexity"},
|
||||
}
|
||||
),
|
||||
}
|
||||
prisma = MagicMock()
|
||||
prisma.get_request_status = MagicMock(return_value="success")
|
||||
|
||||
transaction = await db_writer._common_add_spend_log_transaction_to_daily_transaction(payload, prisma)
|
||||
|
||||
assert transaction is not None
|
||||
assert transaction["api_requests"] == 0
|
||||
assert transaction["successful_requests"] == 0
|
||||
assert transaction["failed_requests"] == 0
|
||||
assert transaction["spend"] == 0.05
|
||||
assert transaction["prompt_tokens"] == 90
|
||||
assert transaction["autorouter_savings_spend"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_requests_still_count_as_requests():
|
||||
"""The negative class for the internal-origin exclusion: an ordinary user request
|
||||
keeps counting, so the exclusion cannot silently zero the whole rollup."""
|
||||
db_writer = DBSpendUpdateWriter()
|
||||
payload = {
|
||||
"user": "user-1",
|
||||
"api_key": "hashed-key",
|
||||
"model": "gpt-4o",
|
||||
"model_group": "gpt-4o",
|
||||
"custom_llm_provider": "openai",
|
||||
"startTime": datetime(2026, 8, 10, 12, 0, tzinfo=timezone.utc),
|
||||
"spend": 0.05,
|
||||
"prompt_tokens": 90,
|
||||
"completion_tokens": 10,
|
||||
"metadata": json.dumps({}),
|
||||
}
|
||||
prisma = MagicMock()
|
||||
prisma.get_request_status = MagicMock(return_value="success")
|
||||
|
||||
transaction = await db_writer._common_add_spend_log_transaction_to_daily_transaction(payload, prisma)
|
||||
|
||||
assert transaction is not None
|
||||
assert transaction["api_requests"] == 1
|
||||
assert transaction["successful_requests"] == 1
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ class TestJudgeCostEstimate:
|
|||
completion_tokens=JUDGE_MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
assert completion_cost > 0
|
||||
assert _estimate_judge_cost_per_call(self.JUDGE_MODEL) == prompt_cost + completion_cost
|
||||
assert _estimate_judge_cost_per_call(None, self.JUDGE_MODEL) == prompt_cost + completion_cost
|
||||
|
||||
def test_estimate_is_not_still_pinned_to_the_old_200_token_budget(self):
|
||||
import litellm as litellm_module
|
||||
|
|
@ -545,7 +545,65 @@ class TestJudgeCostEstimate:
|
|||
model=self.JUDGE_MODEL, prompt_tokens=_JUDGE_PROMPT_TOKENS_ESTIMATE, completion_tokens=200
|
||||
)
|
||||
)
|
||||
assert _estimate_judge_cost_per_call(self.JUDGE_MODEL) > stale
|
||||
assert _estimate_judge_cost_per_call(None, self.JUDGE_MODEL) > stale
|
||||
|
||||
|
||||
class TestJudgeModelValidation:
|
||||
"""A judge the dispatch path cannot resolve fails every sampled turn silently, so
|
||||
start must reject it up front instead of accepting a job that only ever bills
|
||||
shadow calls."""
|
||||
|
||||
@staticmethod
|
||||
def _router() -> MagicMock:
|
||||
router = MagicMock()
|
||||
router.auto_routers = {}
|
||||
router.complexity_routers = {"claude-auto": [MagicMock()]}
|
||||
router.adaptive_routers = {}
|
||||
router.quality_routers = {}
|
||||
router.model_group_alias = {}
|
||||
router.get_model_list = MagicMock(return_value=None)
|
||||
return router
|
||||
|
||||
def test_unresolvable_judge_model_is_a_400(self):
|
||||
from litellm.proxy.management_endpoints.auto_router_endpoints import _validate_judge_model
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_judge_model(self._router(), "opus")
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "opus" in str(exc.value.detail)
|
||||
|
||||
def test_configured_deployment_name_is_accepted(self):
|
||||
from litellm.proxy.management_endpoints.auto_router_endpoints import _validate_judge_model
|
||||
|
||||
router = self._router()
|
||||
router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o"}}])
|
||||
_validate_judge_model(router, "opus")
|
||||
|
||||
def test_provider_qualified_public_name_is_accepted_without_a_router(self):
|
||||
from litellm.proxy.management_endpoints.auto_router_endpoints import _validate_judge_model
|
||||
|
||||
_validate_judge_model(None, "openai/gpt-4o")
|
||||
|
||||
def test_an_auto_router_is_rejected_as_judge(self):
|
||||
from litellm.proxy.management_endpoints.auto_router_endpoints import _validate_judge_model
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_judge_model(self._router(), "claude-auto")
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "auto-router" in str(exc.value.detail)
|
||||
|
||||
def test_estimate_prices_a_deployment_by_its_underlying_model(self):
|
||||
"""The deployment name an admin typed is not a pricing key; the estimate must
|
||||
price the provider model behind it, not fall back to the flat $0.01."""
|
||||
from litellm.proxy.management_endpoints.auto_router_endpoints import _judge_pricing_model
|
||||
|
||||
router = self._router()
|
||||
router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "anthropic/claude-sonnet-5"}}])
|
||||
|
||||
assert _judge_pricing_model(router, "opus") == "anthropic/claude-sonnet-5"
|
||||
assert _judge_pricing_model(None, "openai/gpt-4o") == "openai/gpt-4o"
|
||||
|
||||
|
||||
class TestShadowEvalJobsAreTimeBound:
|
||||
|
|
@ -765,6 +823,7 @@ class TestShadowEvalJobLifecycleEndpoints:
|
|||
record.request_count = 100
|
||||
record.completed_count = 9
|
||||
record.failed_count = 1
|
||||
record.last_error = None
|
||||
record.cost_estimate = 3.0
|
||||
record.cost_actual = 0.42
|
||||
record.created_at = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
|
|
|
|||
|
|
@ -511,6 +511,22 @@ def test_cost_tracking_adds_two_callbacks_when_prisma_set(monkeypatch):
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_starts_the_shadow_eval_lifecycle_loop(monkeypatch):
|
||||
"""The loop owns counter flushes and finishing expired jobs, so a job on a key
|
||||
that goes quiet still ends on schedule; it must be running after startup."""
|
||||
import litellm
|
||||
from litellm.integrations.shadow_eval_logger import ShadowEvalLogger
|
||||
|
||||
logger = ShadowEvalLogger(router_provider=lambda: None, prisma_provider=lambda: None)
|
||||
monkeypatch.setattr(litellm, "callbacks", [logger], raising=False)
|
||||
|
||||
ps._start_shadow_eval_lifecycle_loop()
|
||||
|
||||
assert logger._lifecycle_task is not None and not logger._lifecycle_task.done()
|
||||
logger._lifecycle_task.cancel()
|
||||
|
||||
|
||||
def test_cost_tracking_no_op_when_prisma_missing(monkeypatch):
|
||||
"""Without a prisma_client cost_tracking is a no-op — not an error."""
|
||||
import litellm
|
||||
|
|
|
|||
|
|
@ -225,6 +225,20 @@ describe("ShadowEvalSection", () => {
|
|||
expect(screen.queryByText(/By prompt difficulty/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces the last shadow/judge failure so a growing failed_count is diagnosable", () => {
|
||||
const j = job({ failed_count: 7, last_error: "judge call failed: LLM Provider NOT provided" });
|
||||
mockHooks({ jobs: [j], detail: j });
|
||||
render(<ShadowEvalSection accessToken="token" />);
|
||||
expect(screen.getByText(/LLM Provider NOT provided/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no failure banner when nothing has failed", () => {
|
||||
const j = job({ failed_count: 0, last_error: null });
|
||||
mockHooks({ jobs: [j], detail: j });
|
||||
render(<ShadowEvalSection accessToken="token" />);
|
||||
expect(screen.queryByText(/Last failure/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a stop button for running jobs but not completed ones", () => {
|
||||
const running = job({ status: "running" });
|
||||
mockHooks({ jobs: [running], detail: running });
|
||||
|
|
|
|||
|
|
@ -298,6 +298,12 @@ const JobResults: React.FC<{
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{job.failed_count > 0 && job.last_error ? (
|
||||
<p className="border-b bg-red-50 px-6 py-2 text-xs text-destructive">
|
||||
Last failure: <span className="font-mono">{job.last_error}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{results && results.groups.length > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-col justify-center gap-1 border-b px-6 py-4">
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25391,6 +25391,11 @@ export interface components {
|
|||
failed_count: number;
|
||||
/** Job Id */
|
||||
job_id: string;
|
||||
/**
|
||||
* Last Error
|
||||
* @description The most recent shadow or judge failure, so a growing failed_count is diagnosable
|
||||
*/
|
||||
last_error?: string | null;
|
||||
/**
|
||||
* Request Count
|
||||
* @description Total requests observed on the shadowed key since the job started
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue