diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 8dea8144dc2..11719b8a18f 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -708,8 +708,10 @@ class ComplexityRouter(CustomLogger): override_tier = await self._resolve_keyword_tier_override(user_message, request_kwargs) if override_tier is not None: routed_model = self.get_model_for_tier(override_tier) + cause = "semantic_keyword_match" if self.config.semantic_keyword_matching else "literal_keyword_match" verbose_router_logger.info( - f"ComplexityRouter: keyword rule fired, tier={override_tier.value}, routed_model={routed_model}" + f"ComplexityRouter: routing decision cause={cause}, " + f"tier={override_tier.value}, routed_model={routed_model}" ) return PreRoutingHookResponse( model=routed_model, @@ -720,7 +722,8 @@ class ComplexityRouter(CustomLogger): routed_model = self.get_model_for_tier(tier) verbose_router_logger.info( - f"ComplexityRouter: tier={tier.value}, score={score:.3f}, signals={signals}, routed_model={routed_model}" + f"ComplexityRouter: routing decision cause=complexity_scorer, tier={tier.value}, " + f"score={score:.3f}, signals={signals}, routed_model={routed_model}" ) return PreRoutingHookResponse( diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 12a8a47e1f1..f47c19b2baa 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -5,6 +5,7 @@ Tests the rule-based complexity scoring and tier assignment logic. """ import asyncio +import logging import os import sys from typing import Dict, List @@ -19,6 +20,7 @@ sys.path.insert( import litellm from litellm import Router +from litellm._logging import verbose_router_logger from litellm.router_strategy.complexity_router.complexity_router import ( ComplexityRouter, DimensionScore, @@ -2019,3 +2021,88 @@ class TestSubCallMetadataSanitization: assert sanitized_auth.team_id == "team-1" assert sanitized_auth.api_key == auth.api_key assert auth.budget_reservation == {"reserved_cost": 1.0} + + +class TestRoutingDecisionCauseLogging: + """The info log must name what drove each routing decision so an operator can tell a + literal keyword match, a semantic keyword match, and the complexity scorer apart. + """ + + @pytest.fixture + def router_log_capture(self, caplog): + # verbose_router_logger sets propagate=False, so caplog's root handler never sees + # its records; attach the capture handler directly for the duration of the test. + caplog.set_level(logging.INFO, logger="LiteLLM Router") + verbose_router_logger.addHandler(caplog.handler) + try: + yield caplog + finally: + verbose_router_logger.removeHandler(caplog.handler) + + @pytest.mark.asyncio + async def test_literal_keyword_match_logs_its_cause( + self, mock_router_instance, basic_config, router_log_capture + ): + config = { + **basic_config, + "keyword_tier_rules": [{"keywords": ["deploy to k8s"], "tier": "REASONING"}], + } + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=config, + ) + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "please deploy to k8s now"}], + ) + assert "routing decision cause=literal_keyword_match" in router_log_capture.text + assert "tier=REASONING" in router_log_capture.text + # A literal match must not be mislabelled as semantic. + assert "cause=semantic_keyword_match" not in router_log_capture.text + + @pytest.mark.asyncio + async def test_semantic_keyword_match_logs_its_cause(self, basic_config, router_log_capture): + fake_router = FakeEmbeddingRouter() + config = { + **basic_config, + "keyword_tier_rules": [{"keywords": ["kubernetes deployment"], "tier": "REASONING"}], + "semantic_keyword_matching": True, + "embedding_model": "fake-embed", + "match_threshold": 0.5, + } + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=fake_router, + complexity_router_config=config, + ) + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "help me roll out my k8s cluster today"}], + ) + assert "routing decision cause=semantic_keyword_match" in router_log_capture.text + assert "tier=REASONING" in router_log_capture.text + # A semantic match must not be mislabelled as literal. + assert "cause=literal_keyword_match" not in router_log_capture.text + + @pytest.mark.asyncio + async def test_complexity_scorer_logs_its_cause( + self, mock_router_instance, basic_config, router_log_capture + ): + # No keyword rules -> the scorer decides, and its line must be tagged as such. + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=basic_config, + ) + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "What is the boiling point of water at sea level?"}], + ) + assert "routing decision cause=complexity_scorer" in router_log_capture.text + assert "score=" in router_log_capture.text + assert "cause=literal_keyword_match" not in router_log_capture.text + assert "cause=semantic_keyword_match" not in router_log_capture.text