From 544822b1a8afc96c3cc471a9de8e57035a951e65 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:14:28 -0700 Subject: [PATCH] fix: escalate a stalled keyword-forced tier, and let a blocked toggle clear Two issues Bugbot found on #39809. A keyword_tier_rule forces its tier and returns before any classification runs, so stall escalation never reached that path even though keyword escalation did. That left the one path that can pin a weak model to a whole conversation as the one path a stall could not lift. Stall detection now resolves before the override branch and both paths bump. The dashboard switch disabled itself whenever session pinning or user-turn classification was on, including for a router that already had stall escalation enabled. The conflicting keys stayed set, the backend rejected the save, and the disabled switch was the only way to clear them. It now disables only the off-to-on direction. --- .../complexity_router/complexity_router.py | 22 ++++++++------- .../router_strategy/test_complexity_router.py | 27 +++++++++++++++++++ .../add_model/StallEscalationConfig.test.tsx | 7 +++++ .../add_model/StallEscalationConfig.tsx | 5 +++- 4 files changed, 50 insertions(+), 11 deletions(-) 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<{