fix: complexity_router crashes on list-format message content (OpenAI multi-part messages) (#22761)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tombii 2026-03-05 01:18:49 +01:00 committed by GitHub
parent 9a4bacd85d
commit 28fe9fabae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 51 additions and 15 deletions

View file

@ -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)

View file

@ -866,4 +866,4 @@ class PreRoutingHookResponse(BaseModel):
"""
model: str
messages: Optional[List[Dict[str, str]]]
messages: Optional[List[Dict[str, Any]]]

View file

@ -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: