From 28fe9fabae30240ebd73ec576b8222ca730e48f6 Mon Sep 17 00:00:00 2001 From: tombii Date: Thu, 5 Mar 2026 01:18:49 +0100 Subject: [PATCH] fix: complexity_router crashes on list-format message content (OpenAI multi-part messages) (#22761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: complexity_router fails on list-format message content (OpenAI multi-part messages) When a client sends messages with list-format content (e.g. [{"type": "text", "text": "..."}] as used by the OpenAI JS SDK and other clients), the complexity_router's async_pre_routing_hook skipped those messages because it only handled str content. This caused user_message to be None, the hook returned None, and the router fell through to selecting the complexity_router deployment itself (model="auto_router/complexity_router") which litellm cannot dispatch, resulting in LiteLLMUnknownProvider. Fixes: - Extract text from list-format content parts (type=text) before classifying - Return default_model instead of None when no user message can be extracted, preventing the crash fallthrough - Loosen PreRoutingHookResponse.messages type from Dict[str, str] to Dict[str, Any] to accommodate list-format content values Co-Authored-By: Claude Sonnet 4.6 * fix: update messages type annotation in async_pre_routing_hook to Dict[str, Any] Consistent with PreRoutingHookResponse.messages type change and the list-format content support added in the previous commit. Co-Authored-By: Claude Sonnet 4.6 * fix: normalize None content to empty string in complexity_router message parsing msg.get("content", "") returns None when the key exists with value None (e.g. assistant messages with tool calls). Use `or ""` to normalize None to an empty string explicitly. Co-Authored-By: Claude Sonnet 4.6 * fix: strip whitespace from joined list content parts in complexity_router Prevents leading/trailing spaces when some content parts have empty text values (e.g. " ".join(["", "hello"]) → " hello"). Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../complexity_router/complexity_router.py | 19 ++++++-- litellm/types/router.py | 2 +- .../router_strategy/test_complexity_router.py | 45 ++++++++++++++----- 3 files changed, 51 insertions(+), 15 deletions(-) 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: