mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
feat(router): Add complexity-based auto routing strategy
Adds a rule-based routing strategy that classifies requests by complexity
and routes them to appropriate models - with zero API calls and sub-millisecond
latency.
## Features
- **Zero external API calls** - all scoring is local
- **Sub-millisecond latency** - typically <1ms per classification
- **Weighted multi-dimensional scoring** across 7 dimensions:
- Token count (short=simple, long=complex)
- Code presence (code keywords → complex)
- Reasoning markers ("step by step" → reasoning tier)
- Technical terms (domain complexity)
- Simple indicators ("what is" → simple, negative weight)
- Multi-step patterns (numbered steps)
- Question complexity (multiple questions)
- **Configurable tier boundaries** and model mappings
- **Reasoning override** - 2+ reasoning markers force REASONING tier
## Usage
```yaml
model_list:
- model_name: smart-router
litellm_params:
model: auto_router/complexity_router
complexity_router_config:
tiers:
SIMPLE: gpt-4o-mini
MEDIUM: gpt-4o
COMPLEX: claude-sonnet-4
REASONING: o1-preview
```
Inspired by ClawRouter: https://github.com/BlockRunAI/ClawRouter
## Files Added
- litellm/router_strategy/complexity_router/complexity_router.py - Main router class
- litellm/router_strategy/complexity_router/config.py - Configuration and defaults
- litellm/router_strategy/complexity_router/__init__.py - Package exports
- litellm/router_strategy/complexity_router/README.md - Documentation
- tests/test_litellm/router_strategy/test_complexity_router.py - Test suite (37 tests)
## Files Modified
- litellm/router.py - Integration with pre_routing_hook
- litellm/types/router.py - New config params
This commit is contained in:
parent
21a549d78d
commit
292a0f8d07
7 changed files with 1310 additions and 0 deletions
|
|
@ -189,11 +189,15 @@ if TYPE_CHECKING:
|
|||
AutoRouter,
|
||||
PreRoutingHookResponse,
|
||||
)
|
||||
from litellm.router_strategy.complexity_router.complexity_router import (
|
||||
ComplexityRouter,
|
||||
)
|
||||
|
||||
Span = Union[_Span, Any]
|
||||
else:
|
||||
Span = Any
|
||||
AutoRouter = Any
|
||||
ComplexityRouter = Any
|
||||
PreRoutingHookResponse = Any
|
||||
|
||||
|
||||
|
|
@ -447,6 +451,7 @@ class Router:
|
|||
str, PatternMatchRouter
|
||||
] = {} # {"TEAM_ID": PatternMatchRouter}
|
||||
self.auto_routers: Dict[str, "AutoRouter"] = {}
|
||||
self.complexity_routers: Dict[str, "ComplexityRouter"] = {}
|
||||
|
||||
# Initialize model_group_alias early since it's used in set_model_list
|
||||
self.model_group_alias: Dict[str, Union[str, RouterModelGroupAliasItem]] = (
|
||||
|
|
@ -6277,6 +6282,56 @@ class Router:
|
|||
)
|
||||
self.auto_routers[deployment.model_name] = autor_router
|
||||
|
||||
def _is_complexity_router_deployment(self, litellm_params: LiteLLM_Params) -> bool:
|
||||
"""
|
||||
Check if the deployment is a complexity-router deployment.
|
||||
|
||||
Returns True if the litellm_params model starts with "auto_router/complexity_router"
|
||||
"""
|
||||
if litellm_params.model.startswith("auto_router/complexity_router"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def init_complexity_router_deployment(self, deployment: Deployment):
|
||||
"""
|
||||
Initialize the complexity-router deployment.
|
||||
|
||||
This will initialize the complexity-router and add it to the complexity-routers dictionary.
|
||||
"""
|
||||
from litellm.router_strategy.complexity_router.complexity_router import ComplexityRouter
|
||||
|
||||
complexity_router_config: Optional[
|
||||
dict
|
||||
] = deployment.litellm_params.complexity_router_config or {}
|
||||
|
||||
default_model: Optional[
|
||||
str
|
||||
] = deployment.litellm_params.complexity_router_default_model
|
||||
|
||||
# If no default model specified, try to get from config tiers
|
||||
if default_model is None:
|
||||
tiers = complexity_router_config.get("tiers", {})
|
||||
# Use MEDIUM tier as fallback default
|
||||
default_model = tiers.get("MEDIUM") or tiers.get("SIMPLE")
|
||||
|
||||
if default_model is None:
|
||||
raise ValueError(
|
||||
"complexity_router_default_model is required for complexity-router deployments, "
|
||||
"or configure tiers in complexity_router_config. Please set it in the litellm_params"
|
||||
)
|
||||
|
||||
complexity_router: ComplexityRouter = ComplexityRouter(
|
||||
model_name=deployment.model_name,
|
||||
default_model=default_model,
|
||||
litellm_router_instance=self,
|
||||
complexity_router_config=complexity_router_config,
|
||||
)
|
||||
if deployment.model_name in self.complexity_routers:
|
||||
raise ValueError(
|
||||
f"Complexity-router deployment {deployment.model_name} already exists. Please use a different model name."
|
||||
)
|
||||
self.complexity_routers[deployment.model_name] = complexity_router
|
||||
|
||||
def deployment_is_active_for_environment(self, deployment: Deployment) -> bool:
|
||||
"""
|
||||
Function to check if a llm deployment is active for a given environment. Allows using the same config.yaml across multople environments
|
||||
|
|
@ -6486,6 +6541,12 @@ class Router:
|
|||
if self._is_auto_router_deployment(litellm_params=deployment.litellm_params):
|
||||
self.init_auto_router_deployment(deployment=deployment)
|
||||
|
||||
#########################################################
|
||||
# Check if this is a complexity-router deployment
|
||||
#########################################################
|
||||
if self._is_complexity_router_deployment(litellm_params=deployment.litellm_params):
|
||||
self.init_complexity_router_deployment(deployment=deployment)
|
||||
|
||||
return deployment
|
||||
|
||||
def _initialize_deployment_for_pass_through(
|
||||
|
|
@ -8761,6 +8822,18 @@ class Router:
|
|||
specific_deployment=specific_deployment,
|
||||
)
|
||||
|
||||
#########################################################
|
||||
# Check if any complexity-router should be used
|
||||
#########################################################
|
||||
if model in self.complexity_routers:
|
||||
return await self.complexity_routers[model].async_pre_routing_hook(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
input=input,
|
||||
specific_deployment=specific_deployment,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def get_available_deployment(
|
||||
|
|
|
|||
162
litellm/router_strategy/complexity_router/README.md
Normal file
162
litellm/router_strategy/complexity_router/README.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# Complexity Router
|
||||
|
||||
A rule-based routing strategy that classifies requests by complexity and routes them to appropriate models - with zero API calls and sub-millisecond latency.
|
||||
|
||||
## Overview
|
||||
|
||||
Unlike the semantic `auto_router` which uses embedding-based matching, the `complexity_router` uses weighted rule-based scoring across multiple dimensions to classify request complexity. This approach:
|
||||
|
||||
- **Zero external API calls** - all scoring is local
|
||||
- **Sub-millisecond latency** - typically <1ms per classification
|
||||
- **Predictable behavior** - rule-based scoring is deterministic
|
||||
- **Fully configurable** - weights, thresholds, and keyword lists can be customized
|
||||
|
||||
## How It Works
|
||||
|
||||
The router scores each request across 7 dimensions:
|
||||
|
||||
| Dimension | Description | Weight |
|
||||
|-----------|-------------|--------|
|
||||
| `tokenCount` | Short prompts = simple, long = complex | 0.15 |
|
||||
| `codePresence` | Code keywords (function, class, etc.) | 0.20 |
|
||||
| `reasoningMarkers` | "step by step", "think through", etc. | 0.25 |
|
||||
| `technicalTerms` | Domain complexity indicators | 0.15 |
|
||||
| `simpleIndicators` | "what is", "define" (negative weight) | 0.15 |
|
||||
| `multiStepPatterns` | "first...then", numbered steps | 0.05 |
|
||||
| `questionComplexity` | Multiple question marks | 0.05 |
|
||||
|
||||
The weighted sum is mapped to tiers using configurable boundaries:
|
||||
|
||||
| Tier | Score Range | Typical Use |
|
||||
|------|-------------|-------------|
|
||||
| SIMPLE | < 0.25 | Basic questions, greetings |
|
||||
| MEDIUM | 0.25 - 0.50 | Standard queries |
|
||||
| COMPLEX | 0.50 - 0.75 | Technical, multi-part requests |
|
||||
| REASONING | > 0.75 | Chain-of-thought, analysis |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: smart-router
|
||||
litellm_params:
|
||||
model: auto_router/complexity_router
|
||||
complexity_router_config:
|
||||
tiers:
|
||||
SIMPLE: gpt-4o-mini
|
||||
MEDIUM: gpt-4o
|
||||
COMPLEX: claude-sonnet-4
|
||||
REASONING: o1-preview
|
||||
```
|
||||
|
||||
### Full Configuration
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: smart-router
|
||||
litellm_params:
|
||||
model: auto_router/complexity_router
|
||||
complexity_router_config:
|
||||
# Tier to model mapping
|
||||
tiers:
|
||||
SIMPLE: gpt-4o-mini
|
||||
MEDIUM: gpt-4o
|
||||
COMPLEX: claude-sonnet-4
|
||||
REASONING: o1-preview
|
||||
|
||||
# Tier boundaries (normalized scores)
|
||||
tier_boundaries:
|
||||
simple_medium: 0.25
|
||||
medium_complex: 0.50
|
||||
complex_reasoning: 0.75
|
||||
|
||||
# Token count thresholds
|
||||
token_thresholds:
|
||||
simple: 50 # Below this = "short"
|
||||
complex: 500 # Above this = "long"
|
||||
|
||||
# Dimension weights (must sum to ~1.0)
|
||||
dimension_weights:
|
||||
tokenCount: 0.15
|
||||
codePresence: 0.20
|
||||
reasoningMarkers: 0.25
|
||||
technicalTerms: 0.15
|
||||
simpleIndicators: 0.15
|
||||
multiStepPatterns: 0.05
|
||||
questionComplexity: 0.05
|
||||
|
||||
# Override default keyword lists
|
||||
code_keywords:
|
||||
- function
|
||||
- class
|
||||
- def
|
||||
- async
|
||||
- database
|
||||
|
||||
reasoning_keywords:
|
||||
- step by step
|
||||
- think through
|
||||
- analyze
|
||||
|
||||
# Fallback model if tier cannot be determined
|
||||
default_model: gpt-4o
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, use the model name like any other:
|
||||
|
||||
```python
|
||||
import litellm
|
||||
|
||||
response = litellm.completion(
|
||||
model="smart-router", # Your complexity_router model name
|
||||
messages=[{"role": "user", "content": "What is 2+2?"}]
|
||||
)
|
||||
# Routes to SIMPLE tier (gpt-4o-mini)
|
||||
|
||||
response = litellm.completion(
|
||||
model="smart-router",
|
||||
messages=[{"role": "user", "content": "Think step by step: analyze the performance implications of implementing a distributed consensus algorithm for our microservices architecture."}]
|
||||
)
|
||||
# Routes to REASONING tier (o1-preview)
|
||||
```
|
||||
|
||||
## Special Behaviors
|
||||
|
||||
### Reasoning Override
|
||||
|
||||
If 2+ reasoning markers are detected in the user message, the request is automatically routed to the REASONING tier regardless of the weighted score. This ensures complex reasoning tasks get the appropriate model.
|
||||
|
||||
### System Prompt Handling
|
||||
|
||||
Reasoning markers in the system prompt do **not** trigger the reasoning override. This prevents system prompts like "Think step by step before answering" from forcing all requests to the reasoning tier.
|
||||
|
||||
### Code Detection
|
||||
|
||||
Technical code keywords are detected case-insensitively and include:
|
||||
- Language keywords: `function`, `class`, `def`, `const`, `let`, `var`
|
||||
- Operations: `import`, `export`, `return`, `async`, `await`
|
||||
- Infrastructure: `database`, `api`, `endpoint`, `docker`, `kubernetes`
|
||||
- Actions: `debug`, `implement`, `refactor`, `optimize`
|
||||
|
||||
## Performance
|
||||
|
||||
- **Classification time**: <1ms typical
|
||||
- **Memory usage**: Minimal (compiled regex patterns + keyword sets)
|
||||
- **No external dependencies**: Works offline with no API calls
|
||||
|
||||
## Comparison with auto_router
|
||||
|
||||
| Feature | complexity_router | auto_router |
|
||||
|---------|-------------------|-------------|
|
||||
| Classification | Rule-based scoring | Semantic embedding |
|
||||
| Latency | <1ms | ~100-500ms (embedding API) |
|
||||
| API Calls | None | Requires embedding model |
|
||||
| Training | None | Requires utterance examples |
|
||||
| Customization | Weights, keywords, thresholds | Utterance examples |
|
||||
| Best For | Cost optimization | Intent routing |
|
||||
|
||||
Use `complexity_router` when you want to optimize costs by routing simple queries to cheaper models. Use `auto_router` when you need semantic intent matching (e.g., routing "customer support" queries to a specialized model).
|
||||
22
litellm/router_strategy/complexity_router/__init__.py
Normal file
22
litellm/router_strategy/complexity_router/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""
|
||||
Complexity-based Auto Router
|
||||
|
||||
A rule-based routing strategy that uses weighted scoring across multiple dimensions
|
||||
to classify requests by complexity and route them to appropriate models.
|
||||
|
||||
No external API calls - all scoring is local and <1ms.
|
||||
"""
|
||||
|
||||
from litellm.router_strategy.complexity_router.complexity_router import ComplexityRouter
|
||||
from litellm.router_strategy.complexity_router.config import (
|
||||
ComplexityTier,
|
||||
DEFAULT_COMPLEXITY_CONFIG,
|
||||
ComplexityRouterConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ComplexityRouter",
|
||||
"ComplexityTier",
|
||||
"DEFAULT_COMPLEXITY_CONFIG",
|
||||
"ComplexityRouterConfig",
|
||||
]
|
||||
386
litellm/router_strategy/complexity_router/complexity_router.py
Normal file
386
litellm/router_strategy/complexity_router/complexity_router.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""
|
||||
Complexity-based Auto Router
|
||||
|
||||
A rule-based routing strategy that uses weighted scoring across multiple dimensions
|
||||
to classify requests by complexity and route them to appropriate models.
|
||||
|
||||
No external API calls - all scoring is local and <1ms.
|
||||
|
||||
Inspired by ClawRouter: https://github.com/BlockRunAI/ClawRouter
|
||||
"""
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
from .config import (
|
||||
ComplexityRouterConfig,
|
||||
ComplexityTier,
|
||||
DEFAULT_CODE_KEYWORDS,
|
||||
DEFAULT_COMPLEXITY_CONFIG,
|
||||
DEFAULT_CREATIVE_KEYWORDS,
|
||||
DEFAULT_MULTI_STEP_PATTERNS,
|
||||
DEFAULT_REASONING_KEYWORDS,
|
||||
DEFAULT_SIMPLE_KEYWORDS,
|
||||
DEFAULT_TECHNICAL_KEYWORDS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
from litellm.types.router import PreRoutingHookResponse
|
||||
else:
|
||||
Router = Any
|
||||
PreRoutingHookResponse = Any
|
||||
|
||||
|
||||
class DimensionScore:
|
||||
"""Represents a score for a single dimension with optional signal."""
|
||||
|
||||
__slots__ = ("name", "score", "signal")
|
||||
|
||||
def __init__(self, name: str, score: float, signal: Optional[str] = None):
|
||||
self.name = name
|
||||
self.score = score
|
||||
self.signal = signal
|
||||
|
||||
|
||||
class ComplexityRouter(CustomLogger):
|
||||
"""
|
||||
Rule-based complexity router that classifies requests and routes to appropriate models.
|
||||
|
||||
Handles requests in <1ms with zero external API calls by using weighted scoring
|
||||
across multiple dimensions:
|
||||
- Token count (short=simple, long=complex)
|
||||
- Code presence (code keywords → complex)
|
||||
- Reasoning markers ("step by step", "think through" → reasoning tier)
|
||||
- Technical terms (domain complexity)
|
||||
- Simple indicators ("what is", "define" → simple, negative weight)
|
||||
- Multi-step patterns ("first...then", numbered steps)
|
||||
- Question complexity (multiple questions)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
litellm_router_instance: "Router",
|
||||
complexity_router_config: Optional[Dict[str, Any]] = None,
|
||||
default_model: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize ComplexityRouter.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model/deployment using this router.
|
||||
litellm_router_instance: The LiteLLM Router instance.
|
||||
complexity_router_config: Optional configuration dict from proxy config.
|
||||
default_model: Optional default model to use if tier cannot be determined.
|
||||
"""
|
||||
self.model_name = model_name
|
||||
self.litellm_router_instance = litellm_router_instance
|
||||
|
||||
# Parse config
|
||||
if complexity_router_config:
|
||||
self.config = ComplexityRouterConfig(**complexity_router_config)
|
||||
else:
|
||||
self.config = DEFAULT_COMPLEXITY_CONFIG
|
||||
|
||||
# Override default_model if provided
|
||||
if default_model:
|
||||
self.config.default_model = default_model
|
||||
|
||||
# Build effective keyword lists (use config overrides or defaults)
|
||||
self.code_keywords = self.config.code_keywords or DEFAULT_CODE_KEYWORDS
|
||||
self.reasoning_keywords = self.config.reasoning_keywords or DEFAULT_REASONING_KEYWORDS
|
||||
self.technical_keywords = self.config.technical_keywords or DEFAULT_TECHNICAL_KEYWORDS
|
||||
self.simple_keywords = self.config.simple_keywords or DEFAULT_SIMPLE_KEYWORDS
|
||||
|
||||
# Pre-compile regex patterns for efficiency
|
||||
self._multi_step_patterns = [
|
||||
re.compile(r"first.*then", re.IGNORECASE),
|
||||
re.compile(r"step\s*\d", re.IGNORECASE),
|
||||
re.compile(r"\d+\.\s"),
|
||||
re.compile(r"[a-z]\)\s", re.IGNORECASE),
|
||||
]
|
||||
|
||||
verbose_router_logger.debug(
|
||||
f"ComplexityRouter initialized for {model_name} with tiers: {self.config.tiers}"
|
||||
)
|
||||
|
||||
def _estimate_tokens(self, text: str) -> int:
|
||||
"""
|
||||
Estimate token count from text.
|
||||
Uses a simple heuristic: ~4 characters per token on average.
|
||||
"""
|
||||
return len(text) // 4
|
||||
|
||||
def _score_token_count(self, estimated_tokens: int) -> DimensionScore:
|
||||
"""Score based on token count."""
|
||||
thresholds = self.config.token_thresholds
|
||||
simple_threshold = thresholds.get("simple", 50)
|
||||
complex_threshold = thresholds.get("complex", 500)
|
||||
|
||||
if estimated_tokens < simple_threshold:
|
||||
return DimensionScore(
|
||||
"tokenCount",
|
||||
-1.0,
|
||||
f"short ({estimated_tokens} tokens)"
|
||||
)
|
||||
if estimated_tokens > complex_threshold:
|
||||
return DimensionScore(
|
||||
"tokenCount",
|
||||
1.0,
|
||||
f"long ({estimated_tokens} tokens)"
|
||||
)
|
||||
return DimensionScore("tokenCount", 0, None)
|
||||
|
||||
def _score_keyword_match(
|
||||
self,
|
||||
text: str,
|
||||
keywords: List[str],
|
||||
name: str,
|
||||
signal_label: str,
|
||||
thresholds: Tuple[int, int], # (low, high)
|
||||
scores: Tuple[float, float, float], # (none, low, high)
|
||||
) -> DimensionScore:
|
||||
"""Score based on keyword matches."""
|
||||
low_threshold, high_threshold = thresholds
|
||||
score_none, score_low, score_high = scores
|
||||
|
||||
matches = [kw for kw in keywords if kw.lower() in text]
|
||||
|
||||
if len(matches) >= high_threshold:
|
||||
return DimensionScore(
|
||||
name,
|
||||
score_high,
|
||||
f"{signal_label} ({', '.join(matches[:3])})"
|
||||
)
|
||||
if len(matches) >= low_threshold:
|
||||
return DimensionScore(
|
||||
name,
|
||||
score_low,
|
||||
f"{signal_label} ({', '.join(matches[:3])})"
|
||||
)
|
||||
return DimensionScore(name, score_none, None)
|
||||
|
||||
def _score_multi_step(self, text: str) -> DimensionScore:
|
||||
"""Score based on multi-step patterns."""
|
||||
hits = sum(1 for p in self._multi_step_patterns if p.search(text))
|
||||
if hits > 0:
|
||||
return DimensionScore("multiStepPatterns", 0.5, "multi-step")
|
||||
return DimensionScore("multiStepPatterns", 0, None)
|
||||
|
||||
def _score_question_complexity(self, text: str) -> DimensionScore:
|
||||
"""Score based on number of question marks."""
|
||||
count = text.count("?")
|
||||
if count > 3:
|
||||
return DimensionScore(
|
||||
"questionComplexity",
|
||||
0.5,
|
||||
f"{count} questions"
|
||||
)
|
||||
return DimensionScore("questionComplexity", 0, None)
|
||||
|
||||
def classify(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str] = None
|
||||
) -> Tuple[ComplexityTier, float, List[str]]:
|
||||
"""
|
||||
Classify a prompt by complexity.
|
||||
|
||||
Args:
|
||||
prompt: The user's prompt/message.
|
||||
system_prompt: Optional system prompt for context.
|
||||
|
||||
Returns:
|
||||
Tuple of (tier, score, signals) where:
|
||||
- tier: The ComplexityTier (SIMPLE, MEDIUM, COMPLEX, REASONING)
|
||||
- score: The raw weighted score
|
||||
- signals: List of triggered signals for debugging
|
||||
"""
|
||||
# Combine text for analysis
|
||||
full_text = f"{system_prompt or ''} {prompt}".lower()
|
||||
user_text = prompt.lower()
|
||||
|
||||
# Estimate tokens
|
||||
estimated_tokens = self._estimate_tokens(prompt)
|
||||
|
||||
# Score all dimensions
|
||||
dimensions: List[DimensionScore] = [
|
||||
self._score_token_count(estimated_tokens),
|
||||
self._score_keyword_match(
|
||||
full_text,
|
||||
self.code_keywords,
|
||||
"codePresence",
|
||||
"code",
|
||||
(1, 2),
|
||||
(0, 0.5, 1.0),
|
||||
),
|
||||
# Reasoning markers only from user prompt (not system)
|
||||
self._score_keyword_match(
|
||||
user_text,
|
||||
self.reasoning_keywords,
|
||||
"reasoningMarkers",
|
||||
"reasoning",
|
||||
(1, 2),
|
||||
(0, 0.7, 1.0),
|
||||
),
|
||||
self._score_keyword_match(
|
||||
full_text,
|
||||
self.technical_keywords,
|
||||
"technicalTerms",
|
||||
"technical",
|
||||
(2, 4),
|
||||
(0, 0.5, 1.0),
|
||||
),
|
||||
self._score_keyword_match(
|
||||
full_text,
|
||||
self.simple_keywords,
|
||||
"simpleIndicators",
|
||||
"simple",
|
||||
(1, 2),
|
||||
(0, -1.0, -1.0), # Negative scores for simple indicators
|
||||
),
|
||||
self._score_multi_step(full_text),
|
||||
self._score_question_complexity(prompt),
|
||||
]
|
||||
|
||||
# Collect signals
|
||||
signals = [d.signal for d in dimensions if d.signal is not None]
|
||||
|
||||
# Compute weighted score
|
||||
weights = self.config.dimension_weights
|
||||
weighted_score = sum(
|
||||
d.score * weights.get(d.name, 0)
|
||||
for d in dimensions
|
||||
)
|
||||
|
||||
# Check for reasoning override (2+ reasoning markers)
|
||||
reasoning_matches = [
|
||||
kw for kw in self.reasoning_keywords
|
||||
if kw.lower() in user_text
|
||||
]
|
||||
if len(reasoning_matches) >= 2:
|
||||
return ComplexityTier.REASONING, weighted_score, signals
|
||||
|
||||
# Map score to tier
|
||||
boundaries = self.config.tier_boundaries
|
||||
simple_medium = boundaries.get("simple_medium", 0.25)
|
||||
medium_complex = boundaries.get("medium_complex", 0.50)
|
||||
complex_reasoning = boundaries.get("complex_reasoning", 0.75)
|
||||
|
||||
if weighted_score < simple_medium:
|
||||
tier = ComplexityTier.SIMPLE
|
||||
elif weighted_score < medium_complex:
|
||||
tier = ComplexityTier.MEDIUM
|
||||
elif weighted_score < complex_reasoning:
|
||||
tier = ComplexityTier.COMPLEX
|
||||
else:
|
||||
tier = ComplexityTier.REASONING
|
||||
|
||||
return tier, weighted_score, signals
|
||||
|
||||
def get_model_for_tier(self, tier: ComplexityTier) -> str:
|
||||
"""
|
||||
Get the model name for a given complexity tier.
|
||||
|
||||
Args:
|
||||
tier: The complexity tier.
|
||||
|
||||
Returns:
|
||||
The model name configured for that tier.
|
||||
"""
|
||||
tier_key = tier.value if isinstance(tier, ComplexityTier) else tier
|
||||
|
||||
# Check config tiers mapping
|
||||
model = self.config.tiers.get(tier_key)
|
||||
if model:
|
||||
return model
|
||||
|
||||
# Fallback to default model if configured
|
||||
if self.config.default_model:
|
||||
return self.config.default_model
|
||||
|
||||
# Last resort: return MEDIUM tier model or error
|
||||
medium_model = self.config.tiers.get(ComplexityTier.MEDIUM.value)
|
||||
if medium_model:
|
||||
return medium_model
|
||||
|
||||
raise ValueError(
|
||||
f"No model configured for tier {tier_key} and no default_model set"
|
||||
)
|
||||
|
||||
async def async_pre_routing_hook(
|
||||
self,
|
||||
model: str,
|
||||
request_kwargs: Dict,
|
||||
messages: Optional[List[Dict[str, str]]] = None,
|
||||
input: Optional[Union[str, List]] = None,
|
||||
specific_deployment: Optional[bool] = False,
|
||||
) -> Optional["PreRoutingHookResponse"]:
|
||||
"""
|
||||
Pre-routing hook called before the routing decision.
|
||||
|
||||
Classifies the request by complexity and returns the appropriate model.
|
||||
|
||||
Args:
|
||||
model: The original model name requested.
|
||||
request_kwargs: The request kwargs.
|
||||
messages: The messages in the request.
|
||||
input: Optional input for embeddings.
|
||||
specific_deployment: Whether a specific deployment was requested.
|
||||
|
||||
Returns:
|
||||
PreRoutingHookResponse with the routed model, or None if no routing needed.
|
||||
"""
|
||||
from litellm.types.router import PreRoutingHookResponse
|
||||
|
||||
if messages is None or len(messages) == 0:
|
||||
verbose_router_logger.debug(
|
||||
"ComplexityRouter: No messages provided, skipping routing"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract user message and optional system prompt
|
||||
user_message = ""
|
||||
system_prompt = None
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
if role == "user":
|
||||
user_message = content
|
||||
elif role == "system":
|
||||
system_prompt = content
|
||||
|
||||
# Use the last user message if there are multiple
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
user_message = content
|
||||
break
|
||||
|
||||
if not user_message:
|
||||
verbose_router_logger.debug(
|
||||
"ComplexityRouter: No user message found, skipping routing"
|
||||
)
|
||||
return None
|
||||
|
||||
# Classify the request
|
||||
tier, score, signals = self.classify(user_message, system_prompt)
|
||||
|
||||
# Get the model for this tier
|
||||
routed_model = self.get_model_for_tier(tier)
|
||||
|
||||
verbose_router_logger.info(
|
||||
f"ComplexityRouter: tier={tier.value}, score={score:.3f}, "
|
||||
f"signals={signals}, routed_model={routed_model}"
|
||||
)
|
||||
|
||||
return PreRoutingHookResponse(
|
||||
model=routed_model,
|
||||
messages=messages,
|
||||
)
|
||||
175
litellm/router_strategy/complexity_router/config.py
Normal file
175
litellm/router_strategy/complexity_router/config.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""
|
||||
Configuration for the Complexity Router.
|
||||
|
||||
Contains default keyword lists, weights, tier boundaries, and configuration classes.
|
||||
All values are configurable via proxy config.yaml.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ComplexityTier(str, Enum):
|
||||
"""Complexity tiers for routing decisions."""
|
||||
SIMPLE = "SIMPLE"
|
||||
MEDIUM = "MEDIUM"
|
||||
COMPLEX = "COMPLEX"
|
||||
REASONING = "REASONING"
|
||||
|
||||
|
||||
# ─── Default Keyword Lists ───
|
||||
|
||||
DEFAULT_CODE_KEYWORDS: List[str] = [
|
||||
"function", "class", "def ", "const ", "let ", "var ",
|
||||
"import ", "export ", "return ", "async ", "await ",
|
||||
"try", "catch", "exception", "error", "debug",
|
||||
"api", "endpoint", "request", "response",
|
||||
"database", "sql", "query", "schema",
|
||||
"algorithm", "implement", "refactor", "optimize",
|
||||
"python", "javascript", "typescript", "java", "rust", "golang",
|
||||
"react", "vue", "angular", "node", "docker", "kubernetes",
|
||||
"git", "commit", "merge", "branch", "pull request",
|
||||
]
|
||||
|
||||
DEFAULT_REASONING_KEYWORDS: List[str] = [
|
||||
"step by step", "think through", "let's think",
|
||||
"reason through", "analyze this", "break down",
|
||||
"explain your reasoning", "show your work",
|
||||
"chain of thought", "think carefully",
|
||||
"consider all", "evaluate", "pros and cons",
|
||||
"compare and contrast", "weigh the options",
|
||||
"logical", "deduce", "infer", "conclude",
|
||||
]
|
||||
|
||||
DEFAULT_TECHNICAL_KEYWORDS: List[str] = [
|
||||
"architecture", "distributed", "scalable", "microservice",
|
||||
"machine learning", "neural network", "deep learning",
|
||||
"encryption", "authentication", "authorization",
|
||||
"performance", "latency", "throughput", "benchmark",
|
||||
"concurrency", "parallel", "threading", "async",
|
||||
"memory", "cpu", "gpu", "optimization",
|
||||
"protocol", "tcp", "http", "grpc", "websocket",
|
||||
"kubernetes", "docker", "container", "orchestration",
|
||||
]
|
||||
|
||||
DEFAULT_SIMPLE_KEYWORDS: List[str] = [
|
||||
"what is", "what's", "define ", "definition of",
|
||||
"who is", "who was", "when did", "when was",
|
||||
"where is", "where was", "how many", "how much",
|
||||
"yes or no", "true or false",
|
||||
"simple", "brief", "short", "quick",
|
||||
"hello", "hi ", "hey ", "thanks", "thank you",
|
||||
"goodbye", "bye", "ok", "okay",
|
||||
]
|
||||
|
||||
DEFAULT_MULTI_STEP_PATTERNS: List[str] = [
|
||||
"first", "then", "next", "after that", "finally",
|
||||
"step 1", "step 2", "step 3",
|
||||
"1.", "2.", "3.",
|
||||
"a)", "b)", "c)",
|
||||
]
|
||||
|
||||
DEFAULT_CREATIVE_KEYWORDS: List[str] = [
|
||||
"write a story", "write a poem", "creative writing",
|
||||
"brainstorm", "imagine", "fiction", "narrative",
|
||||
"character", "plot", "setting", "dialogue",
|
||||
]
|
||||
|
||||
|
||||
# ─── Default Dimension Weights ───
|
||||
|
||||
DEFAULT_DIMENSION_WEIGHTS: Dict[str, float] = {
|
||||
"tokenCount": 0.15,
|
||||
"codePresence": 0.20,
|
||||
"reasoningMarkers": 0.25,
|
||||
"technicalTerms": 0.15,
|
||||
"simpleIndicators": 0.15,
|
||||
"multiStepPatterns": 0.05,
|
||||
"questionComplexity": 0.05,
|
||||
}
|
||||
|
||||
|
||||
# ─── Default Tier Boundaries ───
|
||||
|
||||
DEFAULT_TIER_BOUNDARIES: Dict[str, float] = {
|
||||
"simple_medium": 0.25,
|
||||
"medium_complex": 0.50,
|
||||
"complex_reasoning": 0.75,
|
||||
}
|
||||
|
||||
|
||||
# ─── Default Token Thresholds ───
|
||||
|
||||
DEFAULT_TOKEN_THRESHOLDS: Dict[str, int] = {
|
||||
"simple": 50, # Requests under 50 tokens are likely simple
|
||||
"complex": 500, # Requests over 500 tokens are likely complex
|
||||
}
|
||||
|
||||
|
||||
# ─── Default Tier to Model Mapping ───
|
||||
|
||||
DEFAULT_TIER_MODELS: Dict[str, str] = {
|
||||
"SIMPLE": "gpt-4o-mini",
|
||||
"MEDIUM": "gpt-4o",
|
||||
"COMPLEX": "claude-sonnet-4-20250514",
|
||||
"REASONING": "claude-sonnet-4-20250514", # or o1/o3 when available
|
||||
}
|
||||
|
||||
|
||||
class ComplexityRouterConfig(BaseModel):
|
||||
"""Configuration for the ComplexityRouter."""
|
||||
|
||||
# Tier to model mapping
|
||||
tiers: Dict[str, str] = Field(
|
||||
default_factory=lambda: DEFAULT_TIER_MODELS.copy(),
|
||||
description="Mapping of complexity tiers to model names",
|
||||
)
|
||||
|
||||
# Tier boundaries (normalized scores)
|
||||
tier_boundaries: Dict[str, float] = Field(
|
||||
default_factory=lambda: DEFAULT_TIER_BOUNDARIES.copy(),
|
||||
description="Score boundaries between tiers",
|
||||
)
|
||||
|
||||
# Token count thresholds
|
||||
token_thresholds: Dict[str, int] = Field(
|
||||
default_factory=lambda: DEFAULT_TOKEN_THRESHOLDS.copy(),
|
||||
description="Token count thresholds for simple/complex classification",
|
||||
)
|
||||
|
||||
# Dimension weights
|
||||
dimension_weights: Dict[str, float] = Field(
|
||||
default_factory=lambda: DEFAULT_DIMENSION_WEIGHTS.copy(),
|
||||
description="Weights for each scoring dimension",
|
||||
)
|
||||
|
||||
# Keyword lists (overridable)
|
||||
code_keywords: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Keywords indicating code-related content",
|
||||
)
|
||||
reasoning_keywords: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Keywords indicating reasoning-required content",
|
||||
)
|
||||
technical_keywords: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Keywords indicating technical content",
|
||||
)
|
||||
simple_keywords: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Keywords indicating simple/basic queries",
|
||||
)
|
||||
|
||||
# Default model if scoring fails
|
||||
default_model: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Default model to use if tier cannot be determined",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="allow") # Allow additional fields
|
||||
|
||||
|
||||
# Combined default config
|
||||
DEFAULT_COMPLEXITY_CONFIG = ComplexityRouterConfig()
|
||||
|
|
@ -202,6 +202,10 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
|
|||
auto_router_default_model: Optional[str] = None
|
||||
auto_router_embedding_model: Optional[str] = None
|
||||
|
||||
# complexity-router params
|
||||
complexity_router_config: Optional[Dict] = None
|
||||
complexity_router_default_model: Optional[str] = None
|
||||
|
||||
# Batch/File API Params
|
||||
s3_bucket_name: Optional[str] = None
|
||||
s3_encryption_key_id: Optional[str] = None
|
||||
|
|
@ -260,6 +264,9 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
|
|||
auto_router_config: Optional[str] = None,
|
||||
auto_router_default_model: Optional[str] = None,
|
||||
auto_router_embedding_model: Optional[str] = None,
|
||||
# complexity-router params
|
||||
complexity_router_config: Optional[Dict] = None,
|
||||
complexity_router_default_model: Optional[str] = None,
|
||||
# Batch/File API Params
|
||||
s3_bucket_name: Optional[str] = None,
|
||||
s3_encryption_key_id: Optional[str] = None,
|
||||
|
|
|
|||
485
tests/test_litellm/router_strategy/test_complexity_router.py
Normal file
485
tests/test_litellm/router_strategy/test_complexity_router.py
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
"""
|
||||
Tests for the ComplexityRouter.
|
||||
|
||||
Tests the rule-based complexity scoring and tier assignment logic.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.router_strategy.complexity_router.complexity_router import (
|
||||
ComplexityRouter,
|
||||
DimensionScore,
|
||||
)
|
||||
from litellm.router_strategy.complexity_router.config import (
|
||||
ComplexityRouterConfig,
|
||||
ComplexityTier,
|
||||
DEFAULT_COMPLEXITY_CONFIG,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_router_instance():
|
||||
"""Create a mock LiteLLM Router instance."""
|
||||
router = MagicMock()
|
||||
return router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_config() -> Dict:
|
||||
"""Basic configuration with tier mappings."""
|
||||
return {
|
||||
"tiers": {
|
||||
"SIMPLE": "gpt-4o-mini",
|
||||
"MEDIUM": "gpt-4o",
|
||||
"COMPLEX": "claude-sonnet-4-20250514",
|
||||
"REASONING": "o1-preview",
|
||||
},
|
||||
"tier_boundaries": {
|
||||
"simple_medium": 0.25,
|
||||
"medium_complex": 0.50,
|
||||
"complex_reasoning": 0.75,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def complexity_router(mock_router_instance, basic_config):
|
||||
"""Create a ComplexityRouter instance with basic config."""
|
||||
return ComplexityRouter(
|
||||
model_name="test-complexity-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=basic_config,
|
||||
)
|
||||
|
||||
|
||||
class TestDimensionScore:
|
||||
"""Test the DimensionScore class."""
|
||||
|
||||
def test_dimension_score_creation(self):
|
||||
"""Test creating a DimensionScore."""
|
||||
score = DimensionScore("tokenCount", 0.5, "short (25 tokens)")
|
||||
assert score.name == "tokenCount"
|
||||
assert score.score == 0.5
|
||||
assert score.signal == "short (25 tokens)"
|
||||
|
||||
def test_dimension_score_no_signal(self):
|
||||
"""Test creating a DimensionScore without signal."""
|
||||
score = DimensionScore("tokenCount", 0)
|
||||
assert score.name == "tokenCount"
|
||||
assert score.score == 0
|
||||
assert score.signal is None
|
||||
|
||||
|
||||
class TestComplexityRouterInit:
|
||||
"""Test ComplexityRouter initialization."""
|
||||
|
||||
def test_init_with_config(self, mock_router_instance, basic_config):
|
||||
"""Test initialization with configuration."""
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=basic_config,
|
||||
)
|
||||
assert router.model_name == "test-router"
|
||||
assert router.config.tiers["SIMPLE"] == "gpt-4o-mini"
|
||||
assert router.config.tiers["REASONING"] == "o1-preview"
|
||||
|
||||
def test_init_without_config(self, mock_router_instance):
|
||||
"""Test initialization without configuration uses defaults."""
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
)
|
||||
assert router.model_name == "test-router"
|
||||
assert router.config == DEFAULT_COMPLEXITY_CONFIG
|
||||
|
||||
def test_init_with_default_model(self, mock_router_instance, basic_config):
|
||||
"""Test initialization with default_model override."""
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=basic_config,
|
||||
default_model="fallback-model",
|
||||
)
|
||||
assert router.config.default_model == "fallback-model"
|
||||
|
||||
|
||||
class TestTokenScoring:
|
||||
"""Test token count scoring."""
|
||||
|
||||
def test_short_prompt_negative_score(self, complexity_router):
|
||||
"""Short prompts should get negative scores (simple indicator)."""
|
||||
# ~10 tokens (40 chars)
|
||||
tier, score, signals = complexity_router.classify("What is Python?")
|
||||
# Should be classified as SIMPLE due to short length and simple indicator
|
||||
assert tier == ComplexityTier.SIMPLE
|
||||
assert any("short" in s.lower() for s in signals) or any("simple" in s.lower() for s in signals)
|
||||
|
||||
def test_long_prompt_positive_score(self, complexity_router):
|
||||
"""Long prompts should get positive scores (complex indicator)."""
|
||||
# Create a long prompt (~600 tokens)
|
||||
long_prompt = "Explain the following concept in detail: " + " ".join(
|
||||
["distributed systems architecture and microservices patterns"] * 50
|
||||
)
|
||||
tier, score, signals = complexity_router.classify(long_prompt)
|
||||
# Should have positive score and detect long token count or technical terms
|
||||
assert score > 0, f"Expected positive score for long prompt, got {score}"
|
||||
assert any("long" in s.lower() for s in signals) or any("technical" in s.lower() for s in signals)
|
||||
|
||||
|
||||
class TestCodePresenceScoring:
|
||||
"""Test code-related keyword scoring."""
|
||||
|
||||
def test_code_keywords_increase_complexity(self, complexity_router):
|
||||
"""Code keywords should increase complexity score."""
|
||||
prompt = "Write a Python function that implements a binary search algorithm with async support"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should detect code presence
|
||||
assert any("code" in s.lower() for s in signals)
|
||||
# Score should be positive (code keywords add to complexity)
|
||||
# Note: short prompts may still be SIMPLE due to token count, but code signal should be present
|
||||
assert score > -0.5 # Not heavily negative
|
||||
|
||||
def test_multiple_code_keywords(self, complexity_router):
|
||||
"""Multiple code keywords should strongly increase complexity."""
|
||||
prompt = (
|
||||
"Debug this Python function that uses async/await with try/catch "
|
||||
"for API endpoint error handling in the database query"
|
||||
)
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert any("code" in s.lower() for s in signals)
|
||||
|
||||
|
||||
class TestReasoningMarkerScoring:
|
||||
"""Test reasoning marker detection."""
|
||||
|
||||
def test_single_reasoning_marker(self, complexity_router):
|
||||
"""Single reasoning marker should increase score."""
|
||||
prompt = "Think through this problem step by step and explain your reasoning"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert any("reasoning" in s.lower() for s in signals)
|
||||
|
||||
def test_multiple_reasoning_markers_override(self, complexity_router):
|
||||
"""Multiple reasoning markers should force REASONING tier."""
|
||||
prompt = "Let's think step by step. Analyze this carefully and reason through each option. Show your work."
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# 2+ reasoning markers should force REASONING tier
|
||||
assert tier == ComplexityTier.REASONING
|
||||
|
||||
def test_system_prompt_reasoning_not_counted(self, complexity_router):
|
||||
"""Reasoning markers in system prompt should not count for override."""
|
||||
# System prompt has reasoning marker but user message doesn't
|
||||
user_prompt = "What is 2+2?"
|
||||
system_prompt = "Think step by step before answering."
|
||||
tier, score, signals = complexity_router.classify(user_prompt, system_prompt)
|
||||
# Should still be SIMPLE since user message is simple
|
||||
# Note: system prompt reasoning marker adds to score but doesn't trigger override
|
||||
assert tier in [ComplexityTier.SIMPLE, ComplexityTier.MEDIUM]
|
||||
|
||||
|
||||
class TestSimpleIndicatorScoring:
|
||||
"""Test simple indicator detection."""
|
||||
|
||||
def test_simple_greeting(self, complexity_router):
|
||||
"""Simple greetings should be classified as SIMPLE."""
|
||||
tier, score, signals = complexity_router.classify("Hello, how are you?")
|
||||
assert tier == ComplexityTier.SIMPLE
|
||||
|
||||
def test_definition_questions(self, complexity_router):
|
||||
"""Definition questions should be classified as SIMPLE."""
|
||||
prompts = [
|
||||
"What is machine learning?",
|
||||
"Define artificial intelligence",
|
||||
"Who is Alan Turing?",
|
||||
]
|
||||
for prompt in prompts:
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert tier == ComplexityTier.SIMPLE, f"Expected SIMPLE for: {prompt}"
|
||||
|
||||
|
||||
class TestMultiStepPatterns:
|
||||
"""Test multi-step pattern detection."""
|
||||
|
||||
def test_first_then_pattern(self, complexity_router):
|
||||
"""'First...then' patterns should increase complexity."""
|
||||
prompt = "First analyze the data, then create a visualization, then write a report"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert any("multi-step" in s.lower() for s in signals)
|
||||
|
||||
def test_numbered_steps(self, complexity_router):
|
||||
"""Numbered steps should increase complexity."""
|
||||
prompt = "1. Set up the environment 2. Install dependencies 3. Run the tests"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert any("multi-step" in s.lower() for s in signals)
|
||||
|
||||
|
||||
class TestQuestionComplexity:
|
||||
"""Test question complexity scoring."""
|
||||
|
||||
def test_multiple_questions(self, complexity_router):
|
||||
"""Multiple questions should increase complexity."""
|
||||
prompt = "What is the capital? Where is it located? How many people live there? What's the climate like?"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert any("question" in s.lower() for s in signals)
|
||||
|
||||
|
||||
class TestTierAssignment:
|
||||
"""Test tier assignment based on scores."""
|
||||
|
||||
def test_simple_tier(self, complexity_router):
|
||||
"""Simple prompts should get SIMPLE tier."""
|
||||
tier, score, signals = complexity_router.classify("Hi there!")
|
||||
assert tier == ComplexityTier.SIMPLE
|
||||
|
||||
def test_medium_tier(self, complexity_router):
|
||||
"""Moderately complex prompts should get MEDIUM tier."""
|
||||
prompt = "Explain how REST APIs work with HTTP methods"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# This has some technical terms but isn't too complex
|
||||
assert tier in [ComplexityTier.SIMPLE, ComplexityTier.MEDIUM]
|
||||
|
||||
def test_complex_tier(self, complexity_router):
|
||||
"""Complex prompts should get positive complexity score with technical signals."""
|
||||
prompt = (
|
||||
"Design a distributed microservice architecture for a high-throughput "
|
||||
"real-time data processing pipeline with Kubernetes orchestration, "
|
||||
"implementing proper authentication and encryption protocols"
|
||||
)
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should detect technical terms
|
||||
assert any("technical" in s.lower() for s in signals), f"Expected technical signals, got {signals}"
|
||||
# Score should be positive due to technical content
|
||||
assert score > 0, f"Expected positive score, got {score}"
|
||||
|
||||
def test_reasoning_tier(self, complexity_router):
|
||||
"""Reasoning prompts should get REASONING tier."""
|
||||
prompt = (
|
||||
"Think step by step and reason through this: Analyze the pros and cons "
|
||||
"of different database architectures for our distributed system, "
|
||||
"considering performance, scalability, and consistency tradeoffs"
|
||||
)
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
assert tier == ComplexityTier.REASONING
|
||||
|
||||
|
||||
class TestModelSelection:
|
||||
"""Test model selection based on tier."""
|
||||
|
||||
def test_get_model_for_simple(self, complexity_router):
|
||||
"""Should return correct model for SIMPLE tier."""
|
||||
model = complexity_router.get_model_for_tier(ComplexityTier.SIMPLE)
|
||||
assert model == "gpt-4o-mini"
|
||||
|
||||
def test_get_model_for_complex(self, complexity_router):
|
||||
"""Should return correct model for COMPLEX tier."""
|
||||
model = complexity_router.get_model_for_tier(ComplexityTier.COMPLEX)
|
||||
assert model == "claude-sonnet-4-20250514"
|
||||
|
||||
def test_get_model_for_reasoning(self, complexity_router):
|
||||
"""Should return correct model for REASONING tier."""
|
||||
model = complexity_router.get_model_for_tier(ComplexityTier.REASONING)
|
||||
assert model == "o1-preview"
|
||||
|
||||
def test_get_model_fallback_to_default(self, mock_router_instance):
|
||||
"""Should fallback to default_model if tier not configured."""
|
||||
config = {
|
||||
"tiers": {}, # Empty tiers
|
||||
"default_model": "fallback-model",
|
||||
}
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=config,
|
||||
)
|
||||
model = router.get_model_for_tier(ComplexityTier.SIMPLE)
|
||||
assert model == "fallback-model"
|
||||
|
||||
|
||||
class TestPreRoutingHook:
|
||||
"""Test the async_pre_routing_hook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_simple_message(self, complexity_router):
|
||||
"""Test pre-routing hook with a simple message."""
|
||||
messages = [{"role": "user", "content": "Hello!"}]
|
||||
result = await complexity_router.async_pre_routing_hook(
|
||||
model="test-model",
|
||||
request_kwargs={},
|
||||
messages=messages,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.model == "gpt-4o-mini" # SIMPLE tier model
|
||||
assert result.messages == messages
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_complex_message(self, complexity_router):
|
||||
"""Test pre-routing hook with a complex message."""
|
||||
messages = [
|
||||
{"role": "user", "content": (
|
||||
"Design a distributed microservice architecture with Kubernetes "
|
||||
"orchestration, implementing proper authentication, encryption, "
|
||||
"and database optimization for high throughput. Think step by step "
|
||||
"about the performance implications and scalability requirements."
|
||||
)}
|
||||
]
|
||||
result = await complexity_router.async_pre_routing_hook(
|
||||
model="test-model",
|
||||
request_kwargs={},
|
||||
messages=messages,
|
||||
)
|
||||
assert result is not None
|
||||
# Should route to at least MEDIUM tier (technical content + reasoning markers)
|
||||
assert result.model in ["gpt-4o", "claude-sonnet-4-20250514", "o1-preview"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_no_messages(self, complexity_router):
|
||||
"""Test pre-routing hook returns None when no messages."""
|
||||
result = await complexity_router.async_pre_routing_hook(
|
||||
model="test-model",
|
||||
request_kwargs={},
|
||||
messages=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_empty_messages(self, complexity_router):
|
||||
"""Test pre-routing hook returns None when messages empty."""
|
||||
result = await complexity_router.async_pre_routing_hook(
|
||||
model="test-model",
|
||||
request_kwargs={},
|
||||
messages=[],
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_with_system_prompt(self, complexity_router):
|
||||
"""Test pre-routing hook considers system prompt."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello!"},
|
||||
]
|
||||
result = await complexity_router.async_pre_routing_hook(
|
||||
model="test-model",
|
||||
request_kwargs={},
|
||||
messages=messages,
|
||||
)
|
||||
assert result is not None
|
||||
# Should still be SIMPLE
|
||||
assert result.model == "gpt-4o-mini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_routing_hook_reasoning_message(self, complexity_router):
|
||||
"""Test pre-routing hook with reasoning markers."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Let's think step by step and reason through this problem carefully."}
|
||||
]
|
||||
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 model
|
||||
|
||||
|
||||
class TestConfigOverrides:
|
||||
"""Test configuration override functionality."""
|
||||
|
||||
def test_custom_tier_boundaries(self, mock_router_instance):
|
||||
"""Test custom tier boundaries work correctly."""
|
||||
config = {
|
||||
"tiers": {
|
||||
"SIMPLE": "mini-model",
|
||||
"MEDIUM": "medium-model",
|
||||
"COMPLEX": "complex-model",
|
||||
"REASONING": "reasoning-model",
|
||||
},
|
||||
"tier_boundaries": {
|
||||
"simple_medium": -0.5, # Very low threshold - anything above -0.5 is MEDIUM+
|
||||
"medium_complex": -0.3,
|
||||
"complex_reasoning": 0.0,
|
||||
},
|
||||
}
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=config,
|
||||
)
|
||||
# With very low thresholds, even neutral prompts should be COMPLEX or higher
|
||||
tier, score, signals = router.classify(
|
||||
"Explain how HTTP works with REST APIs and distributed systems"
|
||||
)
|
||||
# With boundaries this low, should be at least MEDIUM (anything above -0.5)
|
||||
assert tier != ComplexityTier.SIMPLE, f"Expected non-SIMPLE tier, got {tier} with score {score}"
|
||||
|
||||
def test_custom_token_thresholds(self, mock_router_instance):
|
||||
"""Test custom token thresholds work correctly."""
|
||||
config = {
|
||||
"tiers": {
|
||||
"SIMPLE": "mini-model",
|
||||
"MEDIUM": "medium-model",
|
||||
"COMPLEX": "complex-model",
|
||||
"REASONING": "reasoning-model",
|
||||
},
|
||||
"token_thresholds": {
|
||||
"simple": 10, # Very low - prompts with >10 tokens are not "short"
|
||||
"complex": 100, # Lower than default - prompts with >100 tokens are "long"
|
||||
},
|
||||
}
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=config,
|
||||
)
|
||||
# A longer prompt (~150 tokens) should be considered "long" with these thresholds
|
||||
# Each word is ~1 token, so 30 repetitions of 5 words = ~150 tokens = 600 chars
|
||||
long_prompt = "This is a test prompt word " * 30 # ~180 tokens (720 chars / 4)
|
||||
tier, score, signals = router.classify(long_prompt)
|
||||
# Should get token length signal indicating "long" (>100 tokens with our threshold)
|
||||
assert any("long" in s.lower() if s else False for s in signals), f"Expected 'long' signal, got {signals} for {len(long_prompt)} chars (~{len(long_prompt)//4} tokens)"
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
|
||||
def test_empty_prompt(self, complexity_router):
|
||||
"""Test handling of empty prompt."""
|
||||
tier, score, signals = complexity_router.classify("")
|
||||
assert tier == ComplexityTier.SIMPLE
|
||||
assert score <= 0
|
||||
|
||||
def test_very_long_prompt(self, complexity_router):
|
||||
"""Test handling of very long prompt."""
|
||||
# 16000+ character prompt with technical content to ensure high score
|
||||
long_prompt = "explain the distributed microservice architecture " * 200
|
||||
tier, score, signals = complexity_router.classify(long_prompt)
|
||||
# Should have positive score due to length + technical content
|
||||
assert score > 0, f"Expected positive score for very long prompt, got {score}"
|
||||
# Should detect long token count
|
||||
assert any("long" in s.lower() if s else False for s in signals), f"Expected 'long' signal, got {signals}"
|
||||
|
||||
def test_unicode_prompt(self, complexity_router):
|
||||
"""Test handling of unicode characters."""
|
||||
prompt = "What is 日本語? Explain émojis 🎉 and symbols ∑∏∫"
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# Should not crash, should be classified
|
||||
assert tier in [ComplexityTier.SIMPLE, ComplexityTier.MEDIUM]
|
||||
|
||||
def test_multiline_prompt(self, complexity_router):
|
||||
"""Test handling of multiline prompts with step patterns."""
|
||||
prompt = """
|
||||
Step 1: Analyze the problem.
|
||||
Step 2: Propose a solution.
|
||||
Step 3: Implement it.
|
||||
"""
|
||||
tier, score, signals = complexity_router.classify(prompt)
|
||||
# The "step N" pattern should be detected
|
||||
assert any("multi-step" in s.lower() for s in signals), f"Expected multi-step signal, got {signals}"
|
||||
Loading…
Add table
Reference in a new issue