diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index c4e0c55adc8..6ad21606669 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -326,7 +326,7 @@ class ComplexityRouter(CustomLogger): self, model: str, request_kwargs: Dict, - messages: Optional[List[Dict[str, str]]] = None, + messages: Optional[List[Dict[str, Any]]] = None, input: Optional[Union[str, List]] = None, specific_deployment: Optional[bool] = False, ) -> Optional["PreRoutingHookResponse"]: @@ -359,7 +359,15 @@ class ComplexityRouter(CustomLogger): for msg in reversed(messages): role = msg.get("role", "") - content = msg.get("content", "") + content = msg.get("content") or "" + # content may be a list of content parts (e.g. [{"type": "text", "text": "..."}]) + if isinstance(content, list): + text_parts = [ + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ] + content = " ".join(text_parts).strip() if isinstance(content, str) and content: if role == "user" and user_message is None: user_message = content @@ -368,9 +376,12 @@ class ComplexityRouter(CustomLogger): if user_message is None: verbose_router_logger.debug( - "ComplexityRouter: No user message found, skipping routing" + "ComplexityRouter: No user message found, routing to default model" + ) + return PreRoutingHookResponse( + model=self.config.default_model or self.get_model_for_tier(ComplexityTier.MEDIUM), + messages=messages, ) - return None # Classify the request tier, score, signals = self.classify(user_message, system_prompt) diff --git a/litellm/types/router.py b/litellm/types/router.py index fca731d1f91..d917d845ad2 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -866,4 +866,4 @@ class PreRoutingHookResponse(BaseModel): """ model: str - messages: Optional[List[Dict[str, str]]] + messages: Optional[List[Dict[str, Any]]] diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 8282bc7199f..2ca823f6a12 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -483,7 +483,7 @@ class TestAsyncPreRoutingHookEdgeCases: @pytest.mark.asyncio async def test_pre_routing_hook_no_user_message(self, complexity_router): - """Test pre-routing hook returns None when no user message found.""" + """Test pre-routing hook falls back to default model when no user message found.""" messages = [ {"role": "system", "content": "You are helpful."}, {"role": "assistant", "content": "Hello!"}, @@ -493,21 +493,45 @@ class TestAsyncPreRoutingHookEdgeCases: request_kwargs={}, messages=messages, ) - assert result is None + # Should return default model rather than None (None would cause + # the complexity_router deployment itself to be selected, crashing) + assert result is not None + assert result.model in ["gpt-4o-mini", "gpt-4o", "claude-sonnet-4-20250514", "o1-preview"] @pytest.mark.asyncio - async def test_pre_routing_hook_only_list_content(self, complexity_router): - """Test pre-routing hook returns None when all user content is list type.""" + async def test_pre_routing_hook_list_content(self, complexity_router): + """Test pre-routing hook handles list-format message content (OpenAI multi-part format).""" messages = [ - {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "user", "content": [{"type": "text", "text": "Hello, how are you?"}]}, ] result = await complexity_router.async_pre_routing_hook( model="test-model", request_kwargs={}, messages=messages, ) - # Should return None since we can't extract string content - assert result is None + # Should extract text from list content and classify normally + assert result is not None + assert result.model == "gpt-4o-mini" # "Hello, how are you?" is SIMPLE + + @pytest.mark.asyncio + async def test_pre_routing_hook_list_content_complex(self, complexity_router): + """Test pre-routing hook classifies list-format content by complexity.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Think step by step and reason through this: design a distributed system"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ], + } + ] + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=messages, + ) + assert result is not None + assert result.model == "o1-preview" # REASONING tier @pytest.mark.asyncio async def test_pre_routing_hook_preserves_messages(self, complexity_router): @@ -526,7 +550,7 @@ class TestAsyncPreRoutingHookEdgeCases: @pytest.mark.asyncio async def test_pre_routing_hook_empty_string_content(self, complexity_router): - """Test pre-routing hook returns None for empty string content.""" + """Test pre-routing hook falls back to default model for empty string content.""" messages = [ {"role": "user", "content": ""}, ] @@ -535,8 +559,9 @@ class TestAsyncPreRoutingHookEdgeCases: request_kwargs={}, messages=messages, ) - # Empty string content is treated as "no user message found" - assert result is None + # Empty string content → no extractable user message → routes to default model + assert result is not None + assert result.model in ["gpt-4o-mini", "gpt-4o", "claude-sonnet-4-20250514", "o1-preview"] class TestSingletonMutation: