From 98a0cf306f213f511744502b22ed3f3a2a00d5bc Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 15:17:00 -0700 Subject: [PATCH 1/7] fix(shadow_eval): size the judge output cap for a judge that reasons The cap covers reasoning tokens as well as the verdict, and the models people pick as judges reason before answering whether the call asks them to or not: Anthropic's 5 family thinks adaptively and cannot be told not to, so the reasoning bills against max_tokens with nothing in the request to opt out. At 1500 the reasoning consumed the budget and the reply arrived empty or cut off mid-object, which the attempt recorded as an unparseable judge verdict rather than a result. Headroom costs nothing: max_tokens is a ceiling and only generated tokens bill, so the only movement is that judge calls which used to bill their full budget and return nothing now return a verdict. Deliberately not passing reasoning_effort to bound the reasoning instead: is_thinking_enabled treats any reasoning_effort as thinking-enabled, which drops the forced tool_choice that json_mode relies on and turns thinking on with a 1024-token floor for judges that were not reasoning at all. --- litellm/integrations/shadow_eval_logger.py | 10 ++-- .../integrations/test_shadow_eval_logger.py | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index 27da785331a..a1716c0954d 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,9 +60,13 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object; a tighter budget truncates the JSON -# mid-object and the attempt is lost to an error row. -JUDGE_MAX_OUTPUT_TOKENS: Final = 1500 +# The judge answers with a small JSON object, but the cap covers reasoning tokens too, +# and the models people pick as judges reason before answering whether or not the call +# asks them to (Anthropic's 5 family thinks adaptively and cannot be told not to). A +# budget sized for the JSON alone is spent on invisible reasoning instead, and the reply +# arrives empty or truncated mid-object, which the attempt records as an unparseable +# verdict. Headroom is free: max_tokens is a ceiling, and only generated tokens bill. +JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 5628d69de26..877677505d6 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -120,6 +120,27 @@ def _router( return router +def _reasoning_judge_router(reasoning_tokens, verdict='{"preference": "A", "confidence": 0.9}'): + """A router whose judge arm reasons before it answers, the way Anthropic's 5 family + does whether or not the call asks it to. Reasoning is billed against the caller's own + max_tokens and the reply is cut off at that cap, so a cap that does not clear the + reasoning budget yields a truncated verdict or no verdict at all. One character stands + in for one token, which is what makes the cap the thing under test.""" + router = MagicMock() + router.model_group_alias = {} + router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) + + async def acompletion(**kwargs): + if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: + kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} + return {"choices": [{"message": {"content": "shadow answer"}}]} + budget_for_the_answer = kwargs["max_tokens"] - reasoning_tokens + return {"choices": [{"message": {"content": verdict[: max(0, budget_for_the_answer)]}}]} + + router.acompletion = MagicMock(side_effect=acompletion) + return router + + def _spend_counter(store=None): """In-memory stand-in for the proxy's cross-pod spend counter: reads take the max of the counter and the caller's fallback, exactly like get_current_spend does for a key @@ -1134,6 +1155,34 @@ class TestShadowPipeline: assert row["shadow_cost"] == 0.007 assert logger._test_counter["spend:shadow_eval:job-1"] == 0.007 + async def test_the_judge_output_cap_leaves_room_for_a_reasoning_judge(self): + """The output cap covers reasoning tokens as well as the answer, and the models + people pick as judges reason before answering whether or not the call asks them to. + A cap sized for the verdict JSON alone is spent on reasoning instead and the reply + arrives empty, which the attempt records as an unparseable verdict rather than a + result. The judge here burns a reasoning budget typical of a thinking model on a + comparison task, so the cap has to clear it for the verdict to survive.""" + reasoning_tokens = 2000 + logger = _logger(router=_reasoning_judge_router(reasoning_tokens), prisma=(prisma := _prisma())) + + await logger._run_shadow_eval( + job=_job(), + request_id="req-1", + messages=({"role": "user", "content": "hi"},), + real_text="real answer", + real_model="claude-opus", + real_cost=0.0, + real_classifier_cost=0.0, + real_cache_hit=False, + control_tier=None, + shadow_params={}, + parent_metadata={}, + ) + + row = prisma.db.litellm_shadowevalattempt.create.call_args.kwargs["data"] + assert row["outcome"] in ("real", "shadow", "tie"), row["error"] + assert row["error"] is None + async def test_a_pipeline_error_after_the_shadow_call_keeps_its_billed_cost(self, monkeypatch: pytest.MonkeyPatch): """An unexpected error between the billed shadow call and the attempt write must still record the shadow cost, or the per-key dollar gate undercounts forever.""" From a2f926eb8f7fac36a193a851b035eabb92b27373 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 15:55:19 -0700 Subject: [PATCH 2/7] fix(shadow_eval): correct the judge output cap's causal claim The prior commit claimed claude-sonnet-5 reasons invisibly by default and eats the judge's budget regardless of what the call asks for. Verified against a live proxy: with no thinking param (what _call_judge sends today), forced tool-choice json_mode, native structured output, and even an explicit thinking=adaptive, the model returned 0 reasoning tokens and a clean compact verdict every time, on prompts up to several thousand characters. The real mechanism only shows up with an elevated reasoning_effort or output_config.effort on the request, which happens when the judge_model deployment is configured with one, e.g. an admin pointing the judge at their best reasoning model. Reproduced directly: reasoning_effort=max, 300-token cap, real Anthropic reply came back finish_reason=length, content=None, 299 of 300 tokens spent on reasoning. Same request at 4096 returned a valid verdict. This is a narrower, verified claim than the one it replaces. --- litellm/integrations/shadow_eval_logger.py | 12 +++++----- .../integrations/test_shadow_eval_logger.py | 22 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index a1716c0954d..b554c4bc668 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,12 +60,12 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object, but the cap covers reasoning tokens too, -# and the models people pick as judges reason before answering whether or not the call -# asks them to (Anthropic's 5 family thinks adaptively and cannot be told not to). A -# budget sized for the JSON alone is spent on invisible reasoning instead, and the reply -# arrives empty or truncated mid-object, which the attempt records as an unparseable -# verdict. Headroom is free: max_tokens is a ceiling, and only generated tokens bill. +# The judge answers with a small JSON object, but the cap covers reasoning tokens too. A +# judge_model deployment configured with an elevated reasoning_effort or thinking budget +# (a realistic pick: an admin's best reasoning model doubling as the judge) spends most or +# all of a tight cap on that reasoning, invisibly to this call, and the reply arrives empty +# or truncated mid-object, which the attempt records as an unparseable verdict. Headroom is +# free: max_tokens is a ceiling, and only generated tokens bill. JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 877677505d6..367ad758772 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -121,11 +121,11 @@ def _router( def _reasoning_judge_router(reasoning_tokens, verdict='{"preference": "A", "confidence": 0.9}'): - """A router whose judge arm reasons before it answers, the way Anthropic's 5 family - does whether or not the call asks it to. Reasoning is billed against the caller's own - max_tokens and the reply is cut off at that cap, so a cap that does not clear the - reasoning budget yields a truncated verdict or no verdict at all. One character stands - in for one token, which is what makes the cap the thing under test.""" + """A router whose judge arm reasons before it answers, the way a deployment carrying an + elevated reasoning_effort does. Reasoning is billed against the caller's own max_tokens + and the reply is cut off at that cap, so a cap that does not clear the reasoning budget + yields a truncated verdict or no verdict at all. One character stands in for one token, + which is what makes the cap the thing under test.""" router = MagicMock() router.model_group_alias = {} router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) @@ -1156,12 +1156,12 @@ class TestShadowPipeline: assert logger._test_counter["spend:shadow_eval:job-1"] == 0.007 async def test_the_judge_output_cap_leaves_room_for_a_reasoning_judge(self): - """The output cap covers reasoning tokens as well as the answer, and the models - people pick as judges reason before answering whether or not the call asks them to. - A cap sized for the verdict JSON alone is spent on reasoning instead and the reply - arrives empty, which the attempt records as an unparseable verdict rather than a - result. The judge here burns a reasoning budget typical of a thinking model on a - comparison task, so the cap has to clear it for the verdict to survive.""" + """The output cap covers reasoning tokens as well as the answer, and a judge_model + deployment carrying an elevated reasoning_effort spends that budget before it writes + anything. A cap sized for the verdict JSON alone goes entirely to reasoning and the + reply arrives empty, which the attempt records as an unparseable verdict rather than + a result. The judge here burns a reasoning budget a live claude-sonnet-5 call was + measured at, so the cap has to clear it for the verdict to survive.""" reasoning_tokens = 2000 logger = _logger(router=_reasoning_judge_router(reasoning_tokens), prisma=(prisma := _prisma())) From 2f5bfae1a61b0821b6af9eabb045522adfa7b28a Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:21:13 -0700 Subject: [PATCH 3/7] refactor(shadow_eval): tighten the judge cap comment and type the test helper --- litellm/integrations/shadow_eval_logger.py | 9 +++------ .../integrations/test_shadow_eval_logger.py | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index b554c4bc668..2c56ecb8721 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,12 +60,9 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object, but the cap covers reasoning tokens too. A -# judge_model deployment configured with an elevated reasoning_effort or thinking budget -# (a realistic pick: an admin's best reasoning model doubling as the judge) spends most or -# all of a tight cap on that reasoning, invisibly to this call, and the reply arrives empty -# or truncated mid-object, which the attempt records as an unparseable verdict. Headroom is -# free: max_tokens is a ceiling, and only generated tokens bill. +# The judge answers with a small JSON object, but the cap covers reasoning tokens too: a +# judge deployment carrying an elevated reasoning_effort spends a tight cap before it ever +# answers, and the truncated reply is recorded as an unparseable verdict. JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 367ad758772..9fcbd116f63 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -120,12 +120,12 @@ def _router( return router -def _reasoning_judge_router(reasoning_tokens, verdict='{"preference": "A", "confidence": 0.9}'): +def _reasoning_judge_router( + reasoning_tokens: int, verdict: str = '{"preference": "A", "confidence": 0.9}' +) -> MagicMock: """A router whose judge arm reasons before it answers, the way a deployment carrying an - elevated reasoning_effort does. Reasoning is billed against the caller's own max_tokens - and the reply is cut off at that cap, so a cap that does not clear the reasoning budget - yields a truncated verdict or no verdict at all. One character stands in for one token, - which is what makes the cap the thing under test.""" + elevated reasoning_effort does: reasoning bills against the caller's own max_tokens and + the reply is cut off at that cap. One character stands in for one token.""" router = MagicMock() router.model_group_alias = {} router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) From da5e38ce9c1fe2ba4854952eac0cc2a694e1c38b Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:33:16 -0700 Subject: [PATCH 4/7] refactor(shadow_eval): state the cap's constraint without the rationale --- litellm/integrations/shadow_eval_logger.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index 2c56ecb8721..fc82ebafe09 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,9 +60,8 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object, but the cap covers reasoning tokens too: a -# judge deployment carrying an elevated reasoning_effort spends a tight cap before it ever -# answers, and the truncated reply is recorded as an unparseable verdict. +# Covers the judge's reasoning tokens as well as its small JSON answer: a judge deployment +# carrying an elevated reasoning_effort spends a tight cap before it ever answers. JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 From 5980055d7eae3d1ca28286979c5bd264cd37af57 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:50:35 -0700 Subject: [PATCH 5/7] feat(shadow_eval): say which shape produced an unparseable judge verdict The parser message alone cannot separate a judge that answered with nothing from one truncated mid-object, and the two want opposite fixes. Records the reply's shape, never its text, since no attempt row carries sampled content. --- litellm/integrations/shadow_eval_logger.py | 20 +++- .../integrations/test_shadow_eval_logger.py | 94 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index fc82ebafe09..fb75ef74db9 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -345,6 +345,22 @@ def _failure_detail(e: BaseException) -> str: return f"{type(e).__name__}{location}: {e}" +def _judge_reply_shape(response: object) -> str: + """How an unparseable judge reply was shaped. The parser's own message cannot separate a + judge that answered with nothing from one truncated mid-object, and those want opposite + fixes. Shape only, never the reply text: the judge quotes the sampled turns it compares, + and no attempt row carries sampled content today.""" + try: + choice: Final = response["choices"][0] # pyright: ignore[reportIndexIssue] # judge replies are subscriptable payloads + content: Final = choice["message"]["content"] + finish: Final = choice.get("finish_reason") or "unknown" + except (AttributeError, KeyError, IndexError, TypeError): + return "unreadable judge reply" + served: Final = str(getattr(response, "model", None) or "unknown") + body: Final = f"{len(str(content))} chars" if content else "no content" + return f"finish_reason={finish}, content={body}, model={served}" + + def _call_cost(response: object) -> float: """Price one eval-arm call with the figure the spend pipeline bills: the router client stamps _hidden_params.response_cost from the deployment's own pricing, which the public @@ -1139,7 +1155,9 @@ class ShadowEvalLogger(CustomLogger): verdict: Final = PairwiseVerdict.model_validate(parse_json_verdict(raw)) except Exception as e: # noqa: BLE001 # malformed verdicts become error rows verbose_logger.debug("shadow_eval: unparseable judge verdict: %s", e) - return _CallFailure(f"unparseable judge verdict: {e}", cost=_call_cost(response)) + return _CallFailure( + f"unparseable judge verdict: {e}; {_judge_reply_shape(response)}", cost=_call_cost(response) + ) return _JudgeVerdict( preference=_unmask_preference(verdict.preference, real_is_a), confidence=max(0.0, min(1.0, verdict.confidence)), diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 9fcbd116f63..dbc6d4ec915 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -141,6 +141,27 @@ def _reasoning_judge_router( return router +def _judge_reply_router(content: str | None, finish_reason: str = "stop", served_model: str = "judge-pick") -> MagicMock: + """A router whose judge arm returns a caller-shaped reply, so the shapes that all land + on the same parser error can be posed apart: no content at all, versus JSON cut off + mid-object.""" + router = MagicMock() + router.model_group_alias = {} + router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) + + async def acompletion(**kwargs): + if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: + kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} + return {"choices": [{"message": {"content": "shadow answer"}}]} + return ModelResponse( + model=served_model, + choices=[{"index": 0, "finish_reason": finish_reason, "message": {"role": "assistant", "content": content}}], + ) + + router.acompletion = MagicMock(side_effect=acompletion) + return router + + def _spend_counter(store=None): """In-memory stand-in for the proxy's cross-pod spend counter: reads take the max of the counter and the caller's fallback, exactly like get_current_spend does for a key @@ -1126,6 +1147,79 @@ class TestShadowPipeline: assert row["judge_cost"] == expected_cost assert row["shadow_cost"] == expected_shadow_cost + async def _judge_error(self, router: MagicMock, monkeypatch: pytest.MonkeyPatch) -> str: + import litellm as litellm_module + + monkeypatch.setattr(litellm_module, "completion_cost", lambda completion_response: 0.007) + prisma = _prisma() + await _logger(router=router, prisma=prisma)._run_shadow_eval( + job=_job(), + request_id="req-1", + messages=({"role": "user", "content": "hi"},), + real_text="real answer", + real_model="claude-opus", + real_cost=0.0, + real_classifier_cost=0.0, + real_cache_hit=False, + control_tier=None, + shadow_params={}, + parent_metadata={}, + ) + return prisma.db.litellm_shadowevalattempt.create.call_args.kwargs["data"]["error"] + + async def test_a_judge_that_answered_nothing_is_told_apart_from_one_cut_off( + self, monkeypatch: pytest.MonkeyPatch + ): + """Both land on the same parser message, and they want opposite fixes: a judge + returning no content points at the reply never being text, while one cut off + mid-object points at the output cap. The row has to say which.""" + truncated = '{"preference": "A", "confidence": 0.9, "reasoning": "' + answered_nothing = await self._judge_error(_judge_reply_router(None), monkeypatch) + cut_off = await self._judge_error( + _judge_reply_router(truncated, finish_reason="length"), monkeypatch + ) + + assert "content=no content" in answered_nothing + assert "finish_reason=stop" in answered_nothing + assert f"content={len(truncated)} chars" in cut_off + assert "finish_reason=length" in cut_off + + async def test_an_unparseable_verdict_names_the_model_that_served_it(self, monkeypatch: pytest.MonkeyPatch): + """A judge_model that fans out over deployments hides which one truncates: without + the served model the operator cannot tell a bad deployment from a bad cap.""" + error = await self._judge_error(_judge_reply_router(None, served_model="claude-sonnet-5"), monkeypatch) + + assert "model=claude-sonnet-5" in error + + async def test_a_diagnosed_verdict_error_stays_groupable(self, monkeypatch: pytest.MonkeyPatch): + """The customer groups attempt rows by error text. Every varying part has to sit + after the first semicolon or each row becomes its own group.""" + first = await self._judge_error(_judge_reply_router(None, served_model="model-a"), monkeypatch) + second = await self._judge_error(_judge_reply_router(None, served_model="model-b"), monkeypatch) + + assert first != second + assert first.split(";")[0] == second.split(";")[0] + + async def test_a_judge_reply_that_cannot_be_read_still_records_an_error(self, monkeypatch: pytest.MonkeyPatch): + """The shape reader runs inside the failure path: it must never raise a second time + and cost the row entirely.""" + router = MagicMock() + router.model_group_alias = {} + router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) + + async def acompletion(**kwargs): + if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: + kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} + return {"choices": [{"message": {"content": "shadow answer"}}]} + return {"choices": []} + + router.acompletion = MagicMock(side_effect=acompletion) + + error = await self._judge_error(router, monkeypatch) + + assert "unparseable judge verdict" in error + assert "unreadable judge reply" in error + async def test_an_empty_shadow_reply_still_bills_its_cost(self, monkeypatch: pytest.MonkeyPatch): """A shadow call that returns no extractable text has still billed; pricing it at zero would keep the dollar gate open while shadow calls keep charging the key.""" From 03da725ee4de2414056765f1968794e4c0634ce2 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 09:30:42 -0700 Subject: [PATCH 6/7] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- tests/test_litellm/integrations/test_shadow_eval_logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index d67e957ca16..371f6f75a05 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -159,7 +159,7 @@ def _reasoning_judge_router( if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} return {"choices": [{"message": {"content": "shadow answer"}}]} - budget_for_the_answer = kwargs["max_tokens"] - reasoning_tokens + budget_for_the_answer: Final = kwargs["max_tokens"] - reasoning_tokens return {"choices": [{"message": {"content": verdict[: max(0, budget_for_the_answer)]}}]} router.acompletion = MagicMock(side_effect=acompletion) From 0b3687ec56153225d7b8f2a0c2652bf2f589ce2e Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 09:49:00 -0700 Subject: [PATCH 7/7] fix(shadow_eval): import Final for the test helper's annotation --- tests/test_litellm/integrations/test_shadow_eval_logger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 371f6f75a05..eecd876219e 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -3,6 +3,7 @@ the detached pipeline's single attempt-row write, and the cache-first job lookup import asyncio from datetime import datetime, timedelta, timezone +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest