diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 01b4665a7d9..5815f7577b9 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -3053,6 +3053,14 @@ class ComplexityRouter(CustomLogger): newest_ask: Final = _newest_turn_ask(resolved_messages, self._reminder_markers) escalation_keyword: Final = self._matched_escalation_keyword(newest_ask) if newest_ask is not None else None + # Resolved here rather than beside the classifier because the keyword-override path below + # returns before any classification runs, and a forced tier gets stuck for the same reason + # a classified one does. + stalled: Final = self.config.stall_escalation_enabled and detect_stalled_task( + resolved_messages, + window=self.config.stall_escalation_window, + repeat_threshold=self.config.stall_escalation_repeat_threshold, + ) plan_mode_sentinel: Final = self._matched_plan_mode_signal(request_kwargs, resolved_messages) plan_floor: Final = self._resolve_plan_mode_floor() if plan_mode_sentinel is not None else None @@ -3082,10 +3090,11 @@ class ComplexityRouter(CustomLogger): override: Final = await self._resolve_keyword_tier_override(user_message, request_kwargs) if override is not None: - escalated_tier: Final = ( + keyword_bumped_tier: Final = ( self._escalate_tier(override.tier) if escalation_keyword is not None else override.tier ) - keyword_escalated: Final = escalated_tier != override.tier + escalated_tier: Final = self._escalate_tier(keyword_bumped_tier) if stalled else keyword_bumped_tier + keyword_escalated: Final = keyword_bumped_tier != override.tier routed_tier: Final = ( self._apply_plan_mode_floor(escalated_tier) if plan_floor is not None else escalated_tier ) @@ -3113,6 +3122,7 @@ class ComplexityRouter(CustomLogger): conversation_continuing=conversation_continuing, cause=keyword_cause, tier=routed_tier, + signals=("stall_escalation",) if stalled else None, matched_keyword=plan_mode_sentinel if keyword_plan_floored else override.matched_keyword, escalation_keyword=escalation_keyword, escalated=keyword_escalated, @@ -3136,14 +3146,6 @@ class ComplexityRouter(CustomLogger): escalated: Final = tier != classified_tier if escalated: signals = (*signals, "escalation") - # Recomputed from this request's own tool calls, not remembered from a prior turn: the - # bump lasts only as long as the recent tool calls still look stuck, and lifts itself - # the moment they don't, with nothing to expire or leak past the task that earned it. - stalled: Final = self.config.stall_escalation_enabled and detect_stalled_task( - resolved_messages, - window=self.config.stall_escalation_window, - repeat_threshold=self.config.stall_escalation_repeat_threshold, - ) if stalled: tier = self._escalate_tier(tier) signals = (*signals, "stall_escalation") diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 3ae3165bf62..f2736492d30 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -5990,6 +5990,33 @@ class TestStallEscalation: result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) assert result.model == "claude-sonnet-4-20250514" # SIMPLE -> MEDIUM (keyword) -> COMPLEX (stall) + @pytest.mark.asyncio + async def test_a_keyword_forced_tier_still_escalates_when_stalled(self, mock_router_instance, basic_config): + """A keyword rule forces its tier and returns before any classification runs, so + without its own bump the one path that can pin a weak model to a whole conversation + would be the one path a stall could never lift.""" + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **basic_config, + "stall_escalation_enabled": True, + "keyword_tier_rules": [{"keywords": ["billing"], "tier": "SIMPLE"}], + }, + ) + healthy = await router.async_pre_routing_hook( + model="test-model", request_kwargs={}, messages=[{"role": "user", "content": "a billing question"}] + ) + assert healthy.model == "gpt-4o-mini" # forced SIMPLE, nothing stuck + + stalled = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[*_stalled_tool_history(), {"role": "user", "content": "a billing question"}], + ) + assert stalled.model == "gpt-4o" # forced SIMPLE bumped to MEDIUM + assert "stall_escalation" in stalled.routing_decision["signals"] + @pytest.mark.asyncio async def test_evidence_survives_a_new_human_ask(self, mock_router_instance, basic_config): """A plain follow-up like 'try again' must not erase the stall evidence that came diff --git a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx index 2c346306307..f215849c1a3 100644 --- a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx @@ -105,4 +105,11 @@ describe("StallEscalationConfig", () => { renderConfig({ stall_escalation_enabled: true, session_affinity: true }); expect(screen.queryByLabelText("Repeats before escalating")).not.toBeInTheDocument(); }); + + it("still lets an already-on router turn it off once a blocker appears, which the save needs", () => { + const onChange = renderConfig({ stall_escalation_enabled: true, session_affinity: true }); + expect(toggle()).not.toHaveAttribute("aria-disabled", "true"); + fireEvent.click(toggle()); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ stall_escalation_enabled: undefined })); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx index fdb8c30f3b8..6f2a9cce365 100644 --- a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx @@ -65,7 +65,10 @@ const StallEscalationConfig: React.FC<{