From daaf8711389acd5b741b9065b97bc9240eaa3072 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 30 Aug 2026 09:26:31 +0800 Subject: [PATCH 1/8] fix(router): record savings metadata for adaptive strategies --- litellm/router.py | 1 + .../adaptive_router/adaptive_router.py | 24 ++++++++++++++++- .../complexity_router/complexity_router.py | 1 + .../quality_router/quality_router.py | 23 ++++++++++++++++ litellm/router_strategy/savings_baseline.py | 7 ++++- .../adaptive_router/test_async_pre_routing.py | 27 +++++++++++++++++++ .../router_strategy/test_quality_router.py | 23 ++++++++++++++++ 7 files changed, 104 insertions(+), 2 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index c93c1753f0e..b878190e3d7 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8637,6 +8637,7 @@ class Router: config=config, model_to_prefs=model_to_prefs, model_to_cost=model_to_cost, + litellm_router_instance=self, ) self._register_pre_routing_strategy( registry=self.adaptive_routers, diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py index 1a33ea23bd4..41ae5e80b12 100644 --- a/litellm/router_strategy/adaptive_router/adaptive_router.py +++ b/litellm/router_strategy/adaptive_router/adaptive_router.py @@ -17,7 +17,7 @@ import asyncio import time from collections import OrderedDict from dataclasses import asdict, dataclass -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast from litellm._logging import verbose_router_logger from litellm.litellm_core_utils.prompt_templates.common_utils import ( @@ -50,8 +50,15 @@ from litellm.router_strategy.adaptive_router.signals import ( from litellm.router_strategy.adaptive_router.update_queue import ( AdaptiveRouterUpdateQueue, ) +from litellm.router_strategy.savings_baseline import ( + conversation_is_continuing, + resolve_baseline, +) from litellm.types.utils import StandardLoggingRoutingDecision +if TYPE_CHECKING: + from litellm.router import Router + # Sweep session-state cache when it exceeds this many live entries. Expired # entries are dropped in bulk; amortizes to O(1) per insert. _SESSION_STATE_SWEEP_THRESHOLD: Final[int] = 1024 @@ -91,11 +98,13 @@ class AdaptiveRouter: config: AdaptiveRouterConfig, model_to_prefs: dict[str, AdaptiveRouterPreferences], model_to_cost: dict[str, float], + litellm_router_instance: Router | None = None, ) -> None: self.router_name = router_name self.config = config self.model_to_prefs = model_to_prefs self.model_to_cost = model_to_cost + self.litellm_router_instance = litellm_router_instance self.queue = AdaptiveRouterUpdateQueue() self._cells: dict[tuple[RequestType, str], BanditCell] = {} @@ -203,9 +212,22 @@ class AdaptiveRouter: routed_model=chosen_model, cause="bandit", request_type=request_type.value, + conversation_continuing=conversation_is_continuing(messages), + **self._savings_baseline_fields(), ), ) + def _savings_baseline_fields(self) -> dict[str, str]: + if self.litellm_router_instance is None: + return {} + baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) + if baseline is None: + return {} + fields = {"savings_baseline_model": baseline.model} + if baseline.deployment_id is not None: + fields["savings_baseline_deployment_id"] = baseline.deployment_id + return fields + # ---- Pick model ------------------------------------------------------ async def pick_model( diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 2f4305756e9..3b6513a63f0 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -1751,6 +1751,7 @@ class ComplexityRouter(CustomLogger): ), model_to_prefs=model_to_prefs, model_to_cost=model_to_cost, + litellm_router_instance=self.litellm_router_instance, ) self._adaptive_chosen_model_key = ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY return self.adaptive_router diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index 91e4cad4d27..58cb50bd82b 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -23,6 +23,10 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.router_strategy.complexity_router.complexity_router import ( ComplexityRouter, ) +from litellm.router_strategy.savings_baseline import ( + conversation_is_continuing, + resolve_baseline, +) from litellm.types.utils import StandardLoggingRoutingDecision from .config import QualityRouterConfig, RoutingPreferences @@ -318,6 +322,15 @@ class QualityRouter(CustomLogger): if isinstance(metadata, dict): metadata["quality_router_decision"] = decision + def _savings_fields(self) -> dict[str, str]: + baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) + if baseline is None: + return {} + fields = {"savings_baseline_model": baseline.model} + if baseline.deployment_id is not None: + fields["savings_baseline_deployment_id"] = baseline.deployment_id + return fields + async def async_pre_routing_hook( self, model: str, @@ -364,6 +377,8 @@ class QualityRouter(CustomLogger): router_type="quality", routed_model=self.config.default_model, cause="default_fallback", + conversation_continuing=conversation_is_continuing(messages), + **self._savings_fields(), ), ) @@ -387,6 +402,8 @@ class QualityRouter(CustomLogger): "matched_keyword": matched_keyword, "quality_tier": self._model_quality.get(routed_model), "complexity_tier": None, + "conversation_continuing": conversation_is_continuing(messages), + **self._savings_fields(), }, ) routing_decision: Final = StandardLoggingRoutingDecision( @@ -395,6 +412,8 @@ class QualityRouter(CustomLogger): routed_model=routed_model, cause="keyword", matched_keyword=matched_keyword, + conversation_continuing=conversation_is_continuing(messages), + **self._savings_fields(), ) keyword_quality_tier: Final = self._model_quality.get(routed_model) if keyword_quality_tier is not None: @@ -433,6 +452,8 @@ class QualityRouter(CustomLogger): "matched_keyword": None, "quality_tier": int(quality_tier), "complexity_tier": complexity_name, + "conversation_continuing": conversation_is_continuing(messages), + **self._savings_fields(), }, ) @@ -447,5 +468,7 @@ class QualityRouter(CustomLogger): tier=str(int(quality_tier)), score=score, signals=list(signals), + conversation_continuing=conversation_is_continuing(messages), + **self._savings_fields(), ), ) diff --git a/litellm/router_strategy/savings_baseline.py b/litellm/router_strategy/savings_baseline.py index a2e983a8369..f13a2868388 100644 --- a/litellm/router_strategy/savings_baseline.py +++ b/litellm/router_strategy/savings_baseline.py @@ -14,7 +14,7 @@ bare string with no provider beside them; an operator who writes ``deepseek-r1`` Azure would otherwise be priced against whoever else owns that name. """ -from collections.abc import Iterable +from collections.abc import Iterable, Mapping, Sequence from typing import TYPE_CHECKING, Final, NamedTuple from litellm._logging import verbose_router_logger @@ -45,6 +45,11 @@ class Baseline(NamedTuple): deployment_id: str | None = None +def conversation_is_continuing(messages: Sequence[Mapping[str, object]] | None) -> bool: + """Return whether the request contains evidence of an earlier assistant turn.""" + return any(message.get("role") == "assistant" for message in messages or ()) + + def canonical_model(model: str, custom_llm_provider: str | None = None) -> str | None: """``provider/model``, or ``None`` when the pair names no known provider. diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py index 0a8c230e773..c2efed2e1bf 100644 --- a/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py +++ b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py @@ -12,6 +12,7 @@ from unittest.mock import AsyncMock import pytest from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter +from litellm.router_strategy.savings_baseline import Baseline from litellm.types.router import ( AdaptiveRouterConfig, PreRoutingHookResponse, @@ -43,6 +44,32 @@ async def test_returns_pre_routing_hook_response_with_chosen_model(): assert response.model == "smart" +@pytest.mark.asyncio +async def test_records_savings_baseline_and_conversation_shape(monkeypatch): + router = _make_router() + router.litellm_router_instance = object() + router.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + monkeypatch.setattr( + "litellm.router_strategy.adaptive_router.adaptive_router.resolve_baseline", + lambda router, models: Baseline("openai/smart", "deployment-id"), + ) + + response = await router.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={}, + messages=[ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "answer"}, + {"role": "user", "content": "next"}, + ], + ) + + assert response is not None + assert response.routing_decision["conversation_continuing"] is True + assert response.routing_decision["savings_baseline_model"] == "openai/smart" + assert response.routing_decision["savings_baseline_deployment_id"] == "deployment-id" + + @pytest.mark.asyncio async def test_classifies_last_user_message_for_request_type(): r = _make_router() diff --git a/tests/test_litellm/router_strategy/test_quality_router.py b/tests/test_litellm/router_strategy/test_quality_router.py index 4e87652f8b3..bfb92ca4fb2 100644 --- a/tests/test_litellm/router_strategy/test_quality_router.py +++ b/tests/test_litellm/router_strategy/test_quality_router.py @@ -19,6 +19,7 @@ from litellm.router_strategy.quality_router.config import ( DEFAULT_COMPLEXITY_TO_QUALITY, ) from litellm.router_strategy.quality_router.quality_router import QualityRouter +from litellm.router_strategy.savings_baseline import Baseline def _make_model_list(spec: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -822,6 +823,28 @@ class TestKeywordOverride: class TestDecisionMetadata: + @pytest.mark.asyncio + async def test_decision_includes_savings_baseline_and_conversation_shape(self, quality_router, monkeypatch): + monkeypatch.setattr( + "litellm.router_strategy.quality_router.quality_router.resolve_baseline", + lambda router, models: Baseline("openai/opus-next", "id-opus-next"), + ) + request_kwargs: Dict[str, Any] = {} + response = await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs=request_kwargs, + messages=[ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "continue"}, + ], + ) + + assert response is not None + assert response.routing_decision["conversation_continuing"] is True + assert response.routing_decision["savings_baseline_model"] == "openai/opus-next" + assert response.routing_decision["savings_baseline_deployment_id"] == "id-opus-next" + @pytest.mark.asyncio async def test_hook_stashes_decision_in_request_kwargs_metadata( self, quality_router From 6c6be8698d9a4022053f5517928f7bad5e7048b2 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 30 Aug 2026 09:30:00 +0800 Subject: [PATCH 2/8] test(router): cover first-turn savings metadata --- tests/test_litellm/router_strategy/test_quality_router.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_litellm/router_strategy/test_quality_router.py b/tests/test_litellm/router_strategy/test_quality_router.py index bfb92ca4fb2..d85e137b410 100644 --- a/tests/test_litellm/router_strategy/test_quality_router.py +++ b/tests/test_litellm/router_strategy/test_quality_router.py @@ -845,6 +845,14 @@ class TestDecisionMetadata: assert response.routing_decision["savings_baseline_model"] == "openai/opus-next" assert response.routing_decision["savings_baseline_deployment_id"] == "id-opus-next" + first_turn = await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs={}, + messages=[{"role": "user", "content": "first request"}], + ) + assert first_turn is not None + assert first_turn.routing_decision["conversation_continuing"] is False + @pytest.mark.asyncio async def test_hook_stashes_decision_in_request_kwargs_metadata( self, quality_router From dd6a7dfd9d0f4cc9122c77b6b83838a7144b0b57 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 30 Aug 2026 09:34:11 +0800 Subject: [PATCH 3/8] fix(router): satisfy type discipline budget --- .../router_strategy/adaptive_router/adaptive_router.py | 9 +++++---- litellm/router_strategy/quality_router/quality_router.py | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py index 41ae5e80b12..eea0ef9351f 100644 --- a/litellm/router_strategy/adaptive_router/adaptive_router.py +++ b/litellm/router_strategy/adaptive_router/adaptive_router.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import time from collections import OrderedDict +from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import TYPE_CHECKING, Any, Final, cast @@ -217,13 +218,13 @@ class AdaptiveRouter: ), ) - def _savings_baseline_fields(self) -> dict[str, str]: + def _savings_baseline_fields(self) -> Mapping[str, str]: if self.litellm_router_instance is None: - return {} + return {} # mutable-ok: immutable empty result for absent router baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: - return {} - fields = {"savings_baseline_model": baseline.model} + return {} # mutable-ok: immutable empty result for unresolved baseline + fields = {"savings_baseline_model": baseline.model} # mutable-ok: assemble TypedDict kwargs if baseline.deployment_id is not None: fields["savings_baseline_deployment_id"] = baseline.deployment_id return fields diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index 58cb50bd82b..d4bc01a26cf 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -16,6 +16,7 @@ then cheapest `model_info.input_cost_per_token`). """ import math +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Optional from litellm._logging import verbose_router_logger @@ -322,11 +323,11 @@ class QualityRouter(CustomLogger): if isinstance(metadata, dict): metadata["quality_router_decision"] = decision - def _savings_fields(self) -> dict[str, str]: + def _savings_fields(self) -> Mapping[str, str]: baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: - return {} - fields = {"savings_baseline_model": baseline.model} + return {} # mutable-ok: immutable empty result for unresolved baseline + fields = {"savings_baseline_model": baseline.model} # mutable-ok: assemble TypedDict kwargs if baseline.deployment_id is not None: fields["savings_baseline_deployment_id"] = baseline.deployment_id return fields From 31d5460171c105fa52658423c5380ce0fb9becab Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 30 Aug 2026 09:44:16 +0800 Subject: [PATCH 4/8] fix(router): avoid typed decision expansion errors --- .../adaptive_router/adaptive_router.py | 26 +++++---- .../quality_router/quality_router.py | 57 ++++++++++--------- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py index eea0ef9351f..b6c82d184d2 100644 --- a/litellm/router_strategy/adaptive_router/adaptive_router.py +++ b/litellm/router_strategy/adaptive_router/adaptive_router.py @@ -16,7 +16,6 @@ from __future__ import annotations import asyncio import time from collections import OrderedDict -from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import TYPE_CHECKING, Any, Final, cast @@ -204,27 +203,30 @@ class AdaptiveRouter: if isinstance(kwargs_metadata, dict): kwargs_metadata[ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] = chosen_model + routing_decision: Final = StandardLoggingRoutingDecision( + router_model_name=self.router_name, + router_type="adaptive", + routed_model=chosen_model, + cause="bandit", + request_type=request_type.value, + conversation_continuing=conversation_is_continuing(messages), + ) + routing_decision.update(self._savings_baseline_fields()) return PreRoutingHookResponse( model=chosen_model, messages=messages, - routing_decision=StandardLoggingRoutingDecision( - router_model_name=self.router_name, - router_type="adaptive", - routed_model=chosen_model, - cause="bandit", - request_type=request_type.value, - conversation_continuing=conversation_is_continuing(messages), - **self._savings_baseline_fields(), - ), + routing_decision=routing_decision, ) - def _savings_baseline_fields(self) -> Mapping[str, str]: + def _savings_baseline_fields(self) -> StandardLoggingRoutingDecision: if self.litellm_router_instance is None: return {} # mutable-ok: immutable empty result for absent router baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: return {} # mutable-ok: immutable empty result for unresolved baseline - fields = {"savings_baseline_model": baseline.model} # mutable-ok: assemble TypedDict kwargs + fields: StandardLoggingRoutingDecision = { + "savings_baseline_model": baseline.model, + } # mutable-ok: assemble TypedDict kwargs if baseline.deployment_id is not None: fields["savings_baseline_deployment_id"] = baseline.deployment_id return fields diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index d4bc01a26cf..aba274de68b 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -16,7 +16,6 @@ then cheapest `model_info.input_cost_per_token`). """ import math -from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Optional from litellm._logging import verbose_router_logger @@ -323,11 +322,13 @@ class QualityRouter(CustomLogger): if isinstance(metadata, dict): metadata["quality_router_decision"] = decision - def _savings_fields(self) -> Mapping[str, str]: + def _savings_fields(self) -> StandardLoggingRoutingDecision: baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: return {} # mutable-ok: immutable empty result for unresolved baseline - fields = {"savings_baseline_model": baseline.model} # mutable-ok: assemble TypedDict kwargs + fields: StandardLoggingRoutingDecision = { + "savings_baseline_model": baseline.model, + } # mutable-ok: assemble TypedDict kwargs if baseline.deployment_id is not None: fields["savings_baseline_deployment_id"] = baseline.deployment_id return fields @@ -370,17 +371,18 @@ class QualityRouter(CustomLogger): verbose_router_logger.debug("QualityRouter: No user message found, routing to default model") if not self.config.default_model: raise ValueError("QualityRouter: no user message and no default_model configured") + default_routing_decision: Final = StandardLoggingRoutingDecision( + router_model_name=self.model_name, + router_type="quality", + routed_model=self.config.default_model, + cause="default_fallback", + conversation_continuing=conversation_is_continuing(messages), + ) + default_routing_decision.update(self._savings_fields()) return PreRoutingHookResponse( model=self.config.default_model, messages=messages, - routing_decision=StandardLoggingRoutingDecision( - router_model_name=self.model_name, - router_type="quality", - routed_model=self.config.default_model, - cause="default_fallback", - conversation_continuing=conversation_is_continuing(messages), - **self._savings_fields(), - ), + routing_decision=default_routing_decision, ) # Try keyword override first — it short-circuits complexity classification. @@ -404,25 +406,24 @@ class QualityRouter(CustomLogger): "quality_tier": self._model_quality.get(routed_model), "complexity_tier": None, "conversation_continuing": conversation_is_continuing(messages), - **self._savings_fields(), }, ) - routing_decision: Final = StandardLoggingRoutingDecision( + keyword_routing_decision: Final = StandardLoggingRoutingDecision( router_model_name=self.model_name, router_type="quality", routed_model=routed_model, cause="keyword", matched_keyword=matched_keyword, conversation_continuing=conversation_is_continuing(messages), - **self._savings_fields(), ) + keyword_routing_decision.update(self._savings_fields()) keyword_quality_tier: Final = self._model_quality.get(routed_model) if keyword_quality_tier is not None: - routing_decision["tier"] = str(keyword_quality_tier) + keyword_routing_decision["tier"] = str(keyword_quality_tier) return PreRoutingHookResponse( model=routed_model, messages=messages, - routing_decision=routing_decision, + routing_decision=keyword_routing_decision, ) # No keyword match → complexity classification flow. @@ -454,22 +455,22 @@ class QualityRouter(CustomLogger): "quality_tier": int(quality_tier), "complexity_tier": complexity_name, "conversation_continuing": conversation_is_continuing(messages), - **self._savings_fields(), }, ) + quality_routing_decision: Final = StandardLoggingRoutingDecision( + router_model_name=self.model_name, + router_type="quality", + routed_model=routed_model, + cause="quality_tier", + tier=str(int(quality_tier)), + score=score, + signals=list(signals), + conversation_continuing=conversation_is_continuing(messages), + ) + quality_routing_decision.update(self._savings_fields()) return PreRoutingHookResponse( model=routed_model, messages=messages, - routing_decision=StandardLoggingRoutingDecision( - router_model_name=self.model_name, - router_type="quality", - routed_model=routed_model, - cause="quality_tier", - tier=str(int(quality_tier)), - score=score, - signals=list(signals), - conversation_continuing=conversation_is_continuing(messages), - **self._savings_fields(), - ), + routing_decision=quality_routing_decision, ) From 00f2ce757e71d28b2236df9a031c04bf08b08d0d Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 30 Aug 2026 10:03:03 +0800 Subject: [PATCH 5/8] fix(router): preserve conservative empty-message accounting --- litellm/router_strategy/savings_baseline.py | 2 +- .../adaptive_router/test_async_pre_routing.py | 36 ++++++++++++++----- .../router_strategy/test_quality_router.py | 26 ++++++++++---- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/litellm/router_strategy/savings_baseline.py b/litellm/router_strategy/savings_baseline.py index f13a2868388..0e17025679d 100644 --- a/litellm/router_strategy/savings_baseline.py +++ b/litellm/router_strategy/savings_baseline.py @@ -47,7 +47,7 @@ class Baseline(NamedTuple): def conversation_is_continuing(messages: Sequence[Mapping[str, object]] | None) -> bool: """Return whether the request contains evidence of an earlier assistant turn.""" - return any(message.get("role") == "assistant" for message in messages or ()) + return not messages or any(message.get("role") == "assistant" for message in messages) def canonical_model(model: str, custom_llm_provider: str | None = None) -> str | None: diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py index c2efed2e1bf..9e0efc05247 100644 --- a/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py +++ b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py @@ -7,12 +7,11 @@ model on metadata, and return a PreRoutingHookResponse. Routing is stateless per-turn — `pick_model` does not take a session id. """ -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter -from litellm.router_strategy.savings_baseline import Baseline from litellm.types.router import ( AdaptiveRouterConfig, PreRoutingHookResponse, @@ -45,14 +44,20 @@ async def test_returns_pre_routing_hook_response_with_chosen_model(): @pytest.mark.asyncio -async def test_records_savings_baseline_and_conversation_shape(monkeypatch): +async def test_records_savings_baseline_and_conversation_shape(): router = _make_router() - router.litellm_router_instance = object() + router_instance = MagicMock() + router_instance.model_name_to_deployment_indices = {"fast": [0], "smart": [1]} + router_instance.model_list = [ + {"litellm_params": {"model": "openai/fast"}, "model_info": {"id": "fast-id"}}, + {"litellm_params": {"model": "openai/smart"}, "model_info": {"id": "smart-id"}}, + ] + router_instance.get_deployment_model_info.side_effect = lambda deployment_id, model: { + "input_cost_per_token": 0.00000015 if deployment_id == "fast-id" else 0.000005, + "output_cost_per_token": 0.00000015 if deployment_id == "fast-id" else 0.000005, + } + router.litellm_router_instance = router_instance router.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] - monkeypatch.setattr( - "litellm.router_strategy.adaptive_router.adaptive_router.resolve_baseline", - lambda router, models: Baseline("openai/smart", "deployment-id"), - ) response = await router.async_pre_routing_hook( model="smart-cheap-router", @@ -67,7 +72,20 @@ async def test_records_savings_baseline_and_conversation_shape(monkeypatch): assert response is not None assert response.routing_decision["conversation_continuing"] is True assert response.routing_decision["savings_baseline_model"] == "openai/smart" - assert response.routing_decision["savings_baseline_deployment_id"] == "deployment-id" + assert response.routing_decision["savings_baseline_deployment_id"] == "smart-id" + + +@pytest.mark.asyncio +async def test_empty_messages_use_conservative_continuing_shape(): + router = _make_router() + router.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + response = await router.async_pre_routing_hook( + model="smart-cheap-router", request_kwargs={}, messages=None + ) + + assert response is not None + assert response.routing_decision["conversation_continuing"] is True @pytest.mark.asyncio diff --git a/tests/test_litellm/router_strategy/test_quality_router.py b/tests/test_litellm/router_strategy/test_quality_router.py index d85e137b410..98ba9915342 100644 --- a/tests/test_litellm/router_strategy/test_quality_router.py +++ b/tests/test_litellm/router_strategy/test_quality_router.py @@ -19,7 +19,6 @@ from litellm.router_strategy.quality_router.config import ( DEFAULT_COMPLEXITY_TO_QUALITY, ) from litellm.router_strategy.quality_router.quality_router import QualityRouter -from litellm.router_strategy.savings_baseline import Baseline def _make_model_list(spec: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -824,10 +823,23 @@ class TestKeywordOverride: class TestDecisionMetadata: @pytest.mark.asyncio - async def test_decision_includes_savings_baseline_and_conversation_shape(self, quality_router, monkeypatch): - monkeypatch.setattr( - "litellm.router_strategy.quality_router.quality_router.resolve_baseline", - lambda router, models: Baseline("openai/opus-next", "id-opus-next"), + async def test_decision_includes_savings_baseline_and_conversation_shape(self, quality_router): + quality_router.litellm_router_instance.model_name_to_deployment_indices = { + "haiku": [0], + "sonnet": [1], + "opus": [2], + "opus-next": [3], + } + quality_router.litellm_router_instance.get_deployment_model_info.side_effect = ( + lambda deployment_id, model: { + "input_cost_per_token": { + "id-haiku": 0.000001, + "id-sonnet": 0.000002, + "id-opus": 0.000003, + "id-opus-next": 0.000004, + }[deployment_id], + "output_cost_per_token": 0.000001, + } ) request_kwargs: Dict[str, Any] = {} response = await quality_router.async_pre_routing_hook( @@ -842,8 +854,8 @@ class TestDecisionMetadata: assert response is not None assert response.routing_decision["conversation_continuing"] is True - assert response.routing_decision["savings_baseline_model"] == "openai/opus-next" - assert response.routing_decision["savings_baseline_deployment_id"] == "id-opus-next" + assert response.routing_decision["savings_baseline_model"] == "openai/sonnet" + assert response.routing_decision["savings_baseline_deployment_id"] == "id-sonnet" first_turn = await quality_router.async_pre_routing_hook( model="quality-router-test", From d0562c5f448b2e2f385761699a4276b64d795cf0 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 13 Sep 2026 15:48:25 +0800 Subject: [PATCH 6/8] fix(router): reuse quality savings metadata --- .../adaptive_router/adaptive_router.py | 12 ++++--- .../quality_router/quality_router.py | 31 +++++++++++-------- .../router_strategy/test_quality_router.py | 21 +++++++++++++ .../router_strategy/test_savings_baseline.py | 14 +++++++++ 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py index b6c82d184d2..3fc1b2a8e1e 100644 --- a/litellm/router_strategy/adaptive_router/adaptive_router.py +++ b/litellm/router_strategy/adaptive_router/adaptive_router.py @@ -224,12 +224,14 @@ class AdaptiveRouter: baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: return {} # mutable-ok: immutable empty result for unresolved baseline - fields: StandardLoggingRoutingDecision = { + return { "savings_baseline_model": baseline.model, - } # mutable-ok: assemble TypedDict kwargs - if baseline.deployment_id is not None: - fields["savings_baseline_deployment_id"] = baseline.deployment_id - return fields + **( + {"savings_baseline_deployment_id": baseline.deployment_id} + if baseline.deployment_id is not None + else {} + ), + } # ---- Pick model ------------------------------------------------------ diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index aba274de68b..59af232b498 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -326,12 +326,14 @@ class QualityRouter(CustomLogger): baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: return {} # mutable-ok: immutable empty result for unresolved baseline - fields: StandardLoggingRoutingDecision = { + return { "savings_baseline_model": baseline.model, - } # mutable-ok: assemble TypedDict kwargs - if baseline.deployment_id is not None: - fields["savings_baseline_deployment_id"] = baseline.deployment_id - return fields + **( + {"savings_baseline_deployment_id": baseline.deployment_id} + if baseline.deployment_id is not None + else {} + ), + } async def async_pre_routing_hook( self, @@ -348,6 +350,9 @@ class QualityRouter(CustomLogger): verbose_router_logger.debug("QualityRouter: No messages provided, skipping routing") return None + conversation_continuing: Final = conversation_is_continuing(messages) + savings_fields: Final = self._savings_fields() + # Extract last user message and last system prompt — same rules as # ComplexityRouter.async_pre_routing_hook. user_message: str | None = None @@ -376,9 +381,9 @@ class QualityRouter(CustomLogger): router_type="quality", routed_model=self.config.default_model, cause="default_fallback", - conversation_continuing=conversation_is_continuing(messages), + conversation_continuing=conversation_continuing, ) - default_routing_decision.update(self._savings_fields()) + default_routing_decision.update(savings_fields) return PreRoutingHookResponse( model=self.config.default_model, messages=messages, @@ -405,7 +410,7 @@ class QualityRouter(CustomLogger): "matched_keyword": matched_keyword, "quality_tier": self._model_quality.get(routed_model), "complexity_tier": None, - "conversation_continuing": conversation_is_continuing(messages), + "conversation_continuing": conversation_continuing, }, ) keyword_routing_decision: Final = StandardLoggingRoutingDecision( @@ -414,9 +419,9 @@ class QualityRouter(CustomLogger): routed_model=routed_model, cause="keyword", matched_keyword=matched_keyword, - conversation_continuing=conversation_is_continuing(messages), + conversation_continuing=conversation_continuing, ) - keyword_routing_decision.update(self._savings_fields()) + keyword_routing_decision.update(savings_fields) keyword_quality_tier: Final = self._model_quality.get(routed_model) if keyword_quality_tier is not None: keyword_routing_decision["tier"] = str(keyword_quality_tier) @@ -454,7 +459,7 @@ class QualityRouter(CustomLogger): "matched_keyword": None, "quality_tier": int(quality_tier), "complexity_tier": complexity_name, - "conversation_continuing": conversation_is_continuing(messages), + "conversation_continuing": conversation_continuing, }, ) @@ -466,9 +471,9 @@ class QualityRouter(CustomLogger): tier=str(int(quality_tier)), score=score, signals=list(signals), - conversation_continuing=conversation_is_continuing(messages), + conversation_continuing=conversation_continuing, ) - quality_routing_decision.update(self._savings_fields()) + quality_routing_decision.update(savings_fields) return PreRoutingHookResponse( model=routed_model, messages=messages, diff --git a/tests/test_litellm/router_strategy/test_quality_router.py b/tests/test_litellm/router_strategy/test_quality_router.py index 98ba9915342..be984bcb5a1 100644 --- a/tests/test_litellm/router_strategy/test_quality_router.py +++ b/tests/test_litellm/router_strategy/test_quality_router.py @@ -822,6 +822,27 @@ class TestKeywordOverride: class TestDecisionMetadata: + @pytest.mark.asyncio + @pytest.mark.parametrize("content, cause", [("hi", "quality_tier"), ("write python", "keyword")]) + async def test_conversation_shape_is_read_once_for_metadata_and_savings(self, keyword_router, content, cause): + class CountedMessage(dict): + role_reads = 0 + + def get(self, key, default=None): + if key == "role": + self.role_reads += 1 + return super().get(key, default) + + message = CountedMessage(role="user", content=content) + kwargs: Dict[str, Any] = {} + response = await keyword_router.async_pre_routing_hook("qr", kwargs, [message]) + + assert response is not None + assert response.routing_decision["cause"] == cause + assert response.routing_decision["conversation_continuing"] is False + assert kwargs["metadata"]["quality_router_decision"]["conversation_continuing"] is False + assert message.role_reads == 2 + @pytest.mark.asyncio async def test_decision_includes_savings_baseline_and_conversation_shape(self, quality_router): quality_router.litellm_router_instance.model_name_to_deployment_indices = { diff --git a/tests/test_litellm/router_strategy/test_savings_baseline.py b/tests/test_litellm/router_strategy/test_savings_baseline.py index 5efc73d2dcb..859482edc08 100644 --- a/tests/test_litellm/router_strategy/test_savings_baseline.py +++ b/tests/test_litellm/router_strategy/test_savings_baseline.py @@ -4,6 +4,7 @@ from litellm.router import Router from litellm.router_strategy.savings_baseline import ( Baseline, canonical_model, + conversation_is_continuing, _models_in, _most_expensive, resolve_baseline, @@ -22,6 +23,19 @@ def parent() -> Router: ) +@pytest.mark.parametrize( + "messages, expected", + [ + (None, True), + ([], True), + ([{"role": "user", "content": "hello"}], False), + ([{"role": "user"}, {"role": "assistant"}, {"role": "user"}], True), + ], +) +def test_conversation_shape_for_savings(messages, expected): + assert conversation_is_continuing(messages) is expected + + class TestCanonicalModel: def test_qualifies_a_bare_name_with_the_provider_that_owns_it(self): assert canonical_model("claude-opus-5") == "anthropic/claude-opus-5" From 600d78f1c22a4afef7dc3d9ec91771be1cd5c6eb Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 13 Sep 2026 16:32:55 +0800 Subject: [PATCH 7/8] style: format savings metadata helpers --- litellm/router_strategy/adaptive_router/adaptive_router.py | 4 +--- litellm/router_strategy/quality_router/quality_router.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py index 3fc1b2a8e1e..90938706cbc 100644 --- a/litellm/router_strategy/adaptive_router/adaptive_router.py +++ b/litellm/router_strategy/adaptive_router/adaptive_router.py @@ -227,9 +227,7 @@ class AdaptiveRouter: return { "savings_baseline_model": baseline.model, **( - {"savings_baseline_deployment_id": baseline.deployment_id} - if baseline.deployment_id is not None - else {} + {"savings_baseline_deployment_id": baseline.deployment_id} if baseline.deployment_id is not None else {} ), } diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index 59af232b498..5e28c5a7c15 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -329,9 +329,7 @@ class QualityRouter(CustomLogger): return { "savings_baseline_model": baseline.model, **( - {"savings_baseline_deployment_id": baseline.deployment_id} - if baseline.deployment_id is not None - else {} + {"savings_baseline_deployment_id": baseline.deployment_id} if baseline.deployment_id is not None else {} ), } From 05b64bdde9d44dbffc83963de9004f5026aabd18 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 13 Sep 2026 20:38:39 +0800 Subject: [PATCH 8/8] fix(router): satisfy type discipline budget --- litellm/router_strategy/adaptive_router/adaptive_router.py | 3 ++- litellm/router_strategy/quality_router/quality_router.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py index 90938706cbc..fee6f2d9d50 100644 --- a/litellm/router_strategy/adaptive_router/adaptive_router.py +++ b/litellm/router_strategy/adaptive_router/adaptive_router.py @@ -224,12 +224,13 @@ class AdaptiveRouter: baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: return {} # mutable-ok: immutable empty result for unresolved baseline - return { + fields: Final[StandardLoggingRoutingDecision] = { "savings_baseline_model": baseline.model, **( {"savings_baseline_deployment_id": baseline.deployment_id} if baseline.deployment_id is not None else {} ), } + return fields # ---- Pick model ------------------------------------------------------ diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index 5e28c5a7c15..02cb1a58af1 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -326,12 +326,13 @@ class QualityRouter(CustomLogger): baseline = resolve_baseline(self.litellm_router_instance, self.config.available_models) if baseline is None: return {} # mutable-ok: immutable empty result for unresolved baseline - return { + fields: Final[StandardLoggingRoutingDecision] = { "savings_baseline_model": baseline.model, **( {"savings_baseline_deployment_id": baseline.deployment_id} if baseline.deployment_id is not None else {} ), } + return fields async def async_pre_routing_hook( self, @@ -468,7 +469,7 @@ class QualityRouter(CustomLogger): cause="quality_tier", tier=str(int(quality_tier)), score=score, - signals=list(signals), + signals=list(signals), # mutable-ok: routing decision metadata uses a JSON list conversation_continuing=conversation_continuing, ) quality_routing_decision.update(savings_fields)