refactor(types): move complexity router request models next to their base config (#45780)

* refactor(types): move complexity router request models next to their base config

Part of LIT-9423

* test(types): cover the old auto-router import path for the moved request models

Part of LIT-9423
This commit is contained in:
nate-berri 2026-10-10 12:07:41 -07:00 • committed by GitHub
parent 4606bf532e
commit 0fc6d4d16d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 149 additions and 119 deletions

View file

@ -58,6 +58,10 @@ from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.repositories.verification_token_repository import VerificationTokenRepository
from litellm.router_strategy.complexity_router import ComplexityRouter
from litellm.router_strategy.complexity_router.request_models import (
AutoRouterRoutingTestRequest,
RequestComplexityRouterConfig,
)
from litellm.router_utils.auto_router_model_naming import (
StrategyRouterDependencyRole,
classify_strategy_router_model,
@ -73,12 +77,10 @@ from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterBenchmarkTotals,
AutoRouterCacheBucket,
AutoRouterCacheStats,
AutoRouterRoutingTestRequest,
AutoRouterRoutingTestResponse,
AutoRouterSessionResponse,
ComplexityRouterConfigValidationRequest,
ComplexityRouterConfigValidationResponse,
RequestComplexityRouterConfig,
ShadowEvalDirection,
ShadowEvalJobResponse,
ShadowEvalJobTargetResponse,

View file

@ -38,9 +38,9 @@ from litellm.router_strategy.complexity_router.config import (
ComplexityRouterConfigWrite,
resolve_complexity_router_config_write,
)
from litellm.router_strategy.complexity_router.request_models import RequestComplexityRouterConfig
from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model, strategy_router_dependencies
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.management_endpoints.auto_router_endpoints import RequestComplexityRouterConfig
from litellm.types.router import Deployment, updateDeployment
if TYPE_CHECKING:

View file

@ -0,0 +1,116 @@
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from pydantic import Field, field_validator, model_validator
from litellm.router_strategy.complexity_router.config import ComplexityRouterConfig
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.management_endpoints.auto_router_endpoints import DEFAULT_ROUTING_TEST_ROUTER_NAME
class RequestComplexityRouterConfig(ComplexityRouterConfig):
"""The part of a complexity-router config a request can carry.
`plugins` holds live RoutingPlugin objects, which no JSON body can express and which have no
OpenAPI schema, so it is closed off here rather than left as an arbitrary-type field.
"""
plugins: None = Field(default=None, description="Not settable over HTTP; routing plugins are runtime objects")
classifier_plugin: None = Field( # pyright: ignore[reportIncompatibleVariableOverride] # narrowing to None is the point: runtime objects are not settable over HTTP
default=None, description="Not settable over HTTP; the classifier plugin is a runtime object"
)
class AutoRouterRoutingTestRequest(LiteLLMBaseModel):
"""A single request to classify against a complexity-router config that need not be saved yet.
Carries the same fields the serving path carries, so a dry run classifies what a real turn
would classify. `messages`, `system` and `tools` are forwarded to the routing hook untranslated,
which is why they are typed loosely: the hook reads whatever dialect the surface produced, and
validating them against one surface's schema would reject the others.
"""
prompt: str | None = Field(
default=None,
description="A single ask to route, as an end user would send it. Mutually exclusive with messages",
)
messages: Sequence[Mapping[str, object]] | None = Field(
default=None,
description="The full message list to route, exactly as the serving path would receive it. Mutually exclusive with prompt",
)
system: str | Sequence[Mapping[str, object]] | None = Field(
default=None,
description="The top-level system prompt an Anthropic /v1/messages body carries beside its messages",
)
tools: Sequence[Mapping[str, object]] | None = Field(
default=None,
description="The tool definitions the request advertises, which decide whether the plan-mode floor applies",
)
complexity_router_config: RequestComplexityRouterConfig = Field(
description="The complexity router config to route against, in the shape /model/new accepts",
)
saved_model_id: str | None = Field(
default=None,
min_length=1,
description="Test this saved deployment's server-side configuration instead of the supplied config and default model",
)
default_model: str | None = Field(
default=None,
description="Model to route to when no tier resolves, i.e. complexity_router_default_model",
)
router_name: str = Field(
default=DEFAULT_ROUTING_TEST_ROUTER_NAME,
description="Name reported as the router in the routing decision. Display only",
)
team_id: str | None = Field(
default=None,
description="Team the router is being created for. Required for a team admin, who may only test their own team's routers",
)
@field_validator("messages")
@classmethod
def _reject_messages_no_surface_accepts(
cls, value: Sequence[Mapping[str, object]] | None
) -> Sequence[Mapping[str, object]] | None:
"""Reject what every supported surface rejects, and nothing beyond it.
A real request carrying a message with no string role, or with content that is neither text
nor a block list, is a 400 on the serving path, so answering it here with a routed tier
would promise a decision the request never gets. Only the two keys the dialects agree on
are constrained: anything else in a message stays untranslated and unread.
"""
if value is None:
return value
for index, message in enumerate(value):
if not isinstance(role := message.get("role"), str) or not role.strip():
raise ValueError(f"messages[{index}] needs a non-empty string role")
if (content := message.get("content")) is not None and not isinstance(content, str | list):
raise ValueError(f"messages[{index}] content must be a string, a list of blocks, or null")
return value
@model_validator(mode="after")
def _resolve_request_carrier(self) -> "AutoRouterRoutingTestRequest":
if self.prompt is not None and not self.prompt.strip():
raise ValueError("prompt must not be blank")
if self.messages is not None and not self.messages:
raise ValueError("messages must not be empty")
if (self.prompt is None) == (self.messages is None):
raise ValueError("provide exactly one of prompt or messages")
if self.messages is not None:
return self
return self.model_copy(update={"messages": [{"role": "user", "content": self.prompt}]})
def wire_body(self) -> Mapping[str, object]:
"""The request kwargs a serving-path request would carry for this body.
Every value is handed out by identity rather than copied, so the messages the routing hook
classifies and the messages its raw-body plan-mode scan reads are one value, as they are on
the serving path.
"""
return MappingProxyType(
{
key: value
for key, value in (("messages", self.messages), ("system", self.system), ("tools", self.tools))
if value is not None
}
)

View file

@ -2,31 +2,20 @@
Types for auto-router management endpoints
"""
from collections.abc import Mapping, Sequence
import importlib
from collections.abc import Mapping
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
from pydantic import Field, computed_field, field_validator, model_validator
from litellm.router_strategy.complexity_router.config import ComplexityRouterConfig
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import StandardLoggingRoutingDecision
DEFAULT_ROUTING_TEST_ROUTER_NAME: Final[str] = "auto_router_routing_test"
class RequestComplexityRouterConfig(ComplexityRouterConfig):
"""The part of a complexity-router config a request can carry.
`plugins` holds live RoutingPlugin objects, which no JSON body can express and which have no
OpenAPI schema, so it is closed off here rather than left as an arbitrary-type field.
"""
plugins: None = Field(default=None, description="Not settable over HTTP; routing plugins are runtime objects")
classifier_plugin: None = Field( # pyright: ignore[reportIncompatibleVariableOverride] # narrowing to None is the point: runtime objects are not settable over HTTP
default=None, description="Not settable over HTTP; the classifier plugin is a runtime object"
)
_REQUEST_MODELS_MODULE: Final = "litellm.router_strategy.complexity_router.request_models"
_REQUEST_MODELS_EXPORTS: Final = frozenset({"RequestComplexityRouterConfig", "AutoRouterRoutingTestRequest"})
class ComplexityRouterConfigValidationRequest(LiteLLMBaseModel):
@ -64,101 +53,6 @@ class AutoRouterAvailabilityResponse(LiteLLMBaseModel):
error: str | None = None
class AutoRouterRoutingTestRequest(LiteLLMBaseModel):
"""A single request to classify against a complexity-router config that need not be saved yet.
Carries the same fields the serving path carries, so a dry run classifies what a real turn
would classify. `messages`, `system` and `tools` are forwarded to the routing hook untranslated,
which is why they are typed loosely: the hook reads whatever dialect the surface produced, and
validating them against one surface's schema would reject the others.
"""
prompt: str | None = Field(
default=None,
description="A single ask to route, as an end user would send it. Mutually exclusive with messages",
)
messages: Sequence[Mapping[str, object]] | None = Field(
default=None,
description="The full message list to route, exactly as the serving path would receive it. Mutually exclusive with prompt",
)
system: str | Sequence[Mapping[str, object]] | None = Field(
default=None,
description="The top-level system prompt an Anthropic /v1/messages body carries beside its messages",
)
tools: Sequence[Mapping[str, object]] | None = Field(
default=None,
description="The tool definitions the request advertises, which decide whether the plan-mode floor applies",
)
complexity_router_config: RequestComplexityRouterConfig = Field(
description="The complexity router config to route against, in the shape /model/new accepts",
)
saved_model_id: str | None = Field(
default=None,
min_length=1,
description="Test this saved deployment's server-side configuration instead of the supplied config and default model",
)
default_model: str | None = Field(
default=None,
description="Model to route to when no tier resolves, i.e. complexity_router_default_model",
)
router_name: str = Field(
default=DEFAULT_ROUTING_TEST_ROUTER_NAME,
description="Name reported as the router in the routing decision. Display only",
)
team_id: str | None = Field(
default=None,
description="Team the router is being created for. Required for a team admin, who may only test their own team's routers",
)
@field_validator("messages")
@classmethod
def _reject_messages_no_surface_accepts(
cls, value: Sequence[Mapping[str, object]] | None
) -> Sequence[Mapping[str, object]] | None:
"""Reject what every supported surface rejects, and nothing beyond it.
A real request carrying a message with no string role, or with content that is neither text
nor a block list, is a 400 on the serving path, so answering it here with a routed tier
would promise a decision the request never gets. Only the two keys the dialects agree on
are constrained: anything else in a message stays untranslated and unread.
"""
if value is None:
return value
for index, message in enumerate(value):
if not isinstance(role := message.get("role"), str) or not role.strip():
raise ValueError(f"messages[{index}] needs a non-empty string role")
if (content := message.get("content")) is not None and not isinstance(content, str | list):
raise ValueError(f"messages[{index}] content must be a string, a list of blocks, or null")
return value
@model_validator(mode="after")
def _resolve_request_carrier(self) -> "AutoRouterRoutingTestRequest":
if self.prompt is not None and not self.prompt.strip():
raise ValueError("prompt must not be blank")
if self.messages is not None and not self.messages:
raise ValueError("messages must not be empty")
if (self.prompt is None) == (self.messages is None):
raise ValueError("provide exactly one of prompt or messages")
if self.messages is not None:
return self
return self.model_copy(update={"messages": [{"role": "user", "content": self.prompt}]})
def wire_body(self) -> Mapping[str, object]:
"""The request kwargs a serving-path request would carry for this body.
Every value is handed out by identity rather than copied, so the messages the routing hook
classifies and the messages its raw-body plan-mode scan reads are one value, as they are on
the serving path.
"""
return MappingProxyType(
{
key: value
for key, value in (("messages", self.messages), ("system", self.system), ("tools", self.tools))
if value is not None
}
)
class AutoRouterRoutingTestResponse(LiteLLMBaseModel):
"""Where one prompt would have been routed, and why."""
@ -765,3 +659,9 @@ class ShadowEvalJobResponse(LiteLLMBaseModel):
if all(target.stopped_at is not None for target in self.targets):
return "stopped"
return "running"
def __getattr__(name: str) -> object:
if name in _REQUEST_MODELS_EXPORTS:
return getattr(importlib.import_module(_REQUEST_MODELS_MODULE), name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -36,10 +36,8 @@ from litellm.router_strategy.complexity_router.jev_classifier import (
JevClassifierClient,
JevSystemOneResponse,
)
from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterBenchmarksResponse,
AutoRouterRoutingTestRequest,
)
from litellm.router_strategy.complexity_router.request_models import AutoRouterRoutingTestRequest
from litellm.types.management_endpoints.auto_router_endpoints import AutoRouterBenchmarksResponse
from litellm.types.router import Deployment
from litellm.types.utils import Choices, Message, ModelResponse

View file

@ -264,7 +264,7 @@ async def test_member_authorization_and_persistence_resolve_the_same_classifier(
_strategy_router_write_violation,
update_db_model,
)
from litellm.types.management_endpoints.auto_router_endpoints import RequestComplexityRouterConfig
from litellm.router_strategy.complexity_router.request_models import RequestComplexityRouterConfig
monkeypatch.setenv("LITELLM_SALT_KEY", "member-router-test-salt")
stored_config: Final = {

View file

@ -93,7 +93,7 @@ from litellm.types.router import (
TaggedPreRoutingStrategy,
)
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.management_endpoints.auto_router_endpoints import RequestComplexityRouterConfig
from litellm.router_strategy.complexity_router.request_models import RequestComplexityRouterConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler

View file

@ -0,0 +1,14 @@
import pytest
from litellm.router_strategy.complexity_router import request_models
from litellm.types.management_endpoints import auto_router_endpoints
@pytest.mark.parametrize("name", ["RequestComplexityRouterConfig", "AutoRouterRoutingTestRequest"])
def test_old_import_path_returns_the_moved_request_models(name: str) -> None:
assert getattr(auto_router_endpoints, name) is getattr(request_models, name)
def test_old_import_path_still_raises_for_unknown_attributes() -> None:
with pytest.raises(AttributeError):
getattr(auto_router_endpoints, "NotARequestModel")