diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 1638f20c8e3..af94556763b 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -2009,6 +2009,21 @@ class ComplexityRouter(CustomLogger): return all(abs(forecast["probabilities"][tier.value] - threshold) > margin for tier in deciding_tiers) def _classify_with_heuristic_v2(self, prompt: str) -> ClassificationOutcome: + # The reasoning-marker override is a classifier-wide contract, not a + # heuristic-scorer quirk: an operator who sets reasoning_override_min_score + # (0 = unconditional) expects 2+ markers to promote on every path that + # classifies. The success predictor reads outcome statistics, never the + # ask's markers, so honor the override here through the same gate the + # heuristic scorer uses -- otherwise the setting silently does nothing + # under heuristic_v2 while still being documented as classifier-wide. + overridden: Final = self._score_and_classify(prompt) + if overridden[3] == "reasoning_override": + return ClassificationOutcome( + tier=overridden[0], + score=overridden[1], + signals=overridden[2], + cause="reasoning_override", + ) predictor: Final = self._tier_success_predictor if predictor is None: raise ValueError("heuristic v2 predictor is not configured") diff --git a/tests/unit/router_strategy/test_complexity_router.py b/tests/unit/router_strategy/test_complexity_router.py index e11c5d6c2f3..66f041a5df8 100644 --- a/tests/unit/router_strategy/test_complexity_router.py +++ b/tests/unit/router_strategy/test_complexity_router.py @@ -668,6 +668,45 @@ class TestReasoningMarkerScoring: assert score == 0.25 assert tier == ComplexityTier.REASONING + def test_heuristic_v2_honors_the_zero_floor_override(self, mock_router_instance, basic_config): + """#44527: reasoning_override_min_score=0 must promote on heuristic_v2 too. + + The success predictor reads outcome statistics, never the ask's reasoning + markers, so before the fix a request carrying 2+ exact markers was still + classified by the predictor alone and the operator's explicit 0 floor was + silently ignored. + """ + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **basic_config, + "classifier_type": "heuristic_v2", + "heuristic_v2_artifact": _heuristic_v2_artifact(), + "reasoning_override_min_score": 0.0, + }, + ) + outcome = asyncio.run(router.aclassify("hi, step by step, pros and cons")) + assert outcome.cause == "reasoning_override" + assert outcome.tier == ComplexityTier.REASONING + + def test_heuristic_v2_respects_a_floor_above_the_markers_score(self, mock_router_instance, basic_config): + """A floor above the markers' weighted score leaves heuristic_v2 to the predictor.""" + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **basic_config, + "classifier_type": "heuristic_v2", + "heuristic_v2_artifact": _heuristic_v2_artifact(), + "reasoning_override_min_score": 0.99, + }, + ) + outcome = asyncio.run(router.aclassify("hi, step by step, pros and cons")) + assert outcome.cause != "reasoning_override" + assert outcome.cause == "heuristic_v2" + assert outcome.tier == ComplexityTier.COMPLEX + def test_system_prompt_reasoning_not_counted(self, complexity_router): """Reasoning markers in system prompt should not count for override.""" user_prompt = "What is 2+2?"