mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
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:
parent
9a4bacd85d
commit
28fe9fabae
3 changed files with 51 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -866,4 +866,4 @@ class PreRoutingHookResponse(BaseModel):
|
|||
"""
|
||||
|
||||
model: str
|
||||
messages: Optional[List[Dict[str, str]]]
|
||||
messages: Optional[List[Dict[str, Any]]]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue