Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class _MemberOpenSourceClassifierConfig(LiteLLMBaseModel):

model_config = ConfigDict(extra="forbid")

deployment_name: str | None = None
provider: Literal["jev", "laya", "bespoke", "databricks"] = "jev"
model: str
api_key: None = None
Expand Down
20 changes: 16 additions & 4 deletions litellm/router_strategy/complexity_router/complexity_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
HttpJevClassifierClient,
JevClassifierClient,
JevVerdict,
RouterJevClassifierClient,
build_jev_request,
jev_classifier_cost,
)
Expand Down Expand Up @@ -1331,8 +1332,9 @@ class ComplexityRouter(CustomLogger):
- Question complexity (multiple questions)
"""

@staticmethod
def _build_jev_client(config: OpenSourceClassifierConfig) -> JevClassifierClient:
def _build_jev_client(self, config: OpenSourceClassifierConfig) -> JevClassifierClient:
if config.deployment_name is not None:
return RouterJevClassifierClient(self.litellm_router_instance, config.deployment_name)
if config.provider in ("laya", "bespoke"):
from litellm.llms.oss_decision import oss_connection

Expand Down Expand Up @@ -2318,13 +2320,23 @@ async def _jev_classifier_outcome(
if not self._tier_pools().get(tier_name):
raise ValueError(f"Jev classifier returned tier {tier_name!r}, which has no models configured")
model: Final = response.model or config.model
accounting_provider: Final = "typesafe" if config.provider == "jev" else config.provider
accounting_provider: Final = (
response.classifier_provider
if config.deployment_name and response.classifier_provider
else "typesafe"
if config.provider == "jev"
else config.provider
)
verdict: Final = JevVerdict(
label=answer.choice,
probabilities=answer.probabilities,
confidence=answer.confidence,
model=model,
cost=jev_classifier_cost(response, config.model, accounting_provider),
cost=response.classifier_cost
if config.deployment_name
else jev_classifier_cost(
response, config.model, "typesafe" if config.provider == "jev" else config.provider
),
provider=accounting_provider,
)
if breaker is not None and permit is not None:
Expand Down
12 changes: 11 additions & 1 deletion litellm/router_strategy/complexity_router/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,7 @@ def normalize_classifier_config_aliases(config: Mapping[str, object]) -> Mapping
class OpenSourceClassifierConfig(LiteLLMBaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)

deployment_name: str | None = Field(default=None, min_length=1)
provider: Literal["jev", "laya", "bespoke", "databricks"] = "jev"
model: str = "jev-latest"
api_key: str | None = Field(default=None, description="Provider API key; optional for self-hosted providers")
Expand Down Expand Up @@ -754,6 +755,12 @@ def _reject_blank_api_key(cls, value: str | None) -> str | None:

@model_validator(mode="after")
def _keep_the_environment_key_on_the_environment_base(self) -> "OpenSourceClassifierConfig":
if self.deployment_name is not None:
if not self.deployment_name.strip():
raise ValueError("deployment_name must name a configured decision model")
if self.api_key is not None or self.api_base is not None:
raise ValueError("deployment_name uses the deployment connection; omit api_key and api_base")
return self
if self.provider in ("laya", "bespoke"):
from litellm.llms.oss_decision import validate_oss_api_base, validate_oss_model

Expand Down Expand Up @@ -833,7 +840,10 @@ def _resolve_normalized_complexity_router_config_write(
{
key: value
for key, value in existing.items()
if same_provider and key in ("api_key", "api_base") and (key != "api_key" or same_base)
if same_provider
and not classifier.get("deployment_name")
and key in ("api_key", "api_base")
and (key != "api_key" or same_base)
}
)
return ComplexityRouterConfigWrite(
Expand Down
81 changes: 79 additions & 2 deletions litellm/router_strategy/complexity_router/jev_classifier.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Annotated, Final, Literal, NamedTuple, Protocol, TypeAlias
from typing import TYPE_CHECKING, Annotated, Final, Literal, NamedTuple, Protocol, TypeAlias
from uuid import uuid4

import httpx
Expand All @@ -10,6 +10,7 @@
import litellm
from litellm._logging import verbose_router_logger
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
from litellm.litellm_core_utils.classifier_logging import masked_originating_request
from litellm.litellm_core_utils.internal_call_metadata import (
effective_turn_off_message_logging,
forwarded_internal_call_metadata,
Expand All @@ -24,9 +25,13 @@
TypeSafePassthroughLoggingHandler,
)
from litellm.router_strategy.complexity_router.config import DEFAULT_JEV_INSTRUCTIONS as _DEFAULT_JEV_INSTRUCTIONS
from litellm.types.decisions import DecisionsResponse
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import AUTOROUTER_CLASSIFIER_CALL_ORIGIN

if TYPE_CHECKING:
from litellm.router import Router

JevProbability: TypeAlias = Annotated[float, Field(ge=0.0, le=1.0)]
ClassifierProvider: TypeAlias = Literal["typesafe", "laya", "bespoke", "databricks"]
DEFAULT_JEV_INSTRUCTIONS: Final = _DEFAULT_JEV_INSTRUCTIONS
Expand Down Expand Up @@ -69,6 +74,8 @@ class JevSystemOneResponse(LiteLLMBaseModel):

model: str | None = None
answers: Mapping[str, JevChoiceAnswer]
classifier_cost: float | None = None
classifier_provider: str | None = None
usage: JevUsage | None = None


Expand All @@ -81,6 +88,76 @@ async def evaluate(
) -> JevSystemOneResponse: ...


class _DecisionsRouter(Protocol):
async def adecisions(
self,
*,
model: str,
state: str,
questions: Mapping[str, object],
timeout: float,
num_retries: int,
disable_fallbacks: bool,
metadata: Mapping[str, object],
proxy_server_request: Mapping[str, object],
turn_off_message_logging: bool | None,
litellm_session_id: str = "",
litellm_trace_id: str = "",
) -> object: ...


_PARENT_METADATA: Final = TypeAdapter[Mapping[str, object] | None](Mapping[str, object] | None)


class RouterJevClassifierClient:
def __init__(self, router: "Router", deployment_name: str) -> None:
self._router: _DecisionsRouter = router
self._deployment_name = deployment_name

async def evaluate(
self,
request: JevSystemOneRequest,
timeout_s: float,
request_kwargs: Mapping[str, object] | None = None,
) -> JevSystemOneResponse:
parent: Final = request_kwargs or MappingProxyType({})
metadata: Final = {
**forwarded_internal_call_metadata(
_PARENT_METADATA.validate_python(parent.get("litellm_metadata") or parent.get("metadata")),
AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
),
INTERNAL_CALL_ORIGIN_METADATA_KEY: AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
}
payload: Final = {
"state": request.state,
"questions": {key: value.model_dump() for key, value in request.questions.items()},
}
response: Final = DecisionsResponse.model_validate(
await self._router.adecisions(
model=self._deployment_name,
state=request.state,
questions={key: value.model_dump() for key, value in request.questions.items()},
timeout=timeout_s,
num_retries=0,
disable_fallbacks=True,
metadata=metadata,
proxy_server_request={
"originating_request_masked": masked_originating_request(request_kwargs),
"body": {"model": self._deployment_name, **payload},
},
turn_off_message_logging=effective_turn_off_message_logging(request_kwargs),
**parent_session_kwargs(request_kwargs),
)
)
return JevSystemOneResponse.model_validate(
{
**response.model_dump(),
"classifier_cost": response.hidden_params.get("response_cost"),
"classifier_provider": response.hidden_params.get("custom_llm_provider"),
}
)


class HttpJevClassifierClient:
def __init__(
self,
Expand Down Expand Up @@ -214,7 +291,7 @@ class JevVerdict(NamedTuple):
confidence: float
model: str
cost: float | None
provider: ClassifierProvider = "typesafe"
provider: str = "typesafe"


class _RegistryPricing(LiteLLMBaseModel):
Expand Down
10 changes: 7 additions & 3 deletions litellm/router_utils/auto_router_model_naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,13 @@ def strategy_router_dependencies(
else ()
)
+ (
_named(
f"{accounting_provider}/{decision_classifier.get('model', 'jev-latest')}",
"evaluation",
(
_named(decision_classifier.get("deployment_name"), "classifier")
if decision_classifier.get("deployment_name")
else _named(
f"{accounting_provider}/{decision_classifier.get('model', 'jev-latest')}",
"evaluation",
)
)
if complexity.get("classifier_type") == "oss_classifier"
else ()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,26 @@ async def test_jev_evaluation_obeys_each_containing_scope(
return
await operation
assert not catalog.get_model_list(f"{provider}/{model}")


@pytest.mark.asyncio
@pytest.mark.parametrize("restricted", ["key", "team", None])
async def test_deployed_decision_classifier_requires_key_and_team_access(catalog: Router, restricted: str | None) -> None:
permitted: Final = ["allowed", "other"]
operation: Final = authorize_member_auto_router_dependencies(
config=validate_member_auto_router_config({
"tiers": {"SIMPLE": "allowed"},
"classifier_type": "oss_classifier",
"opensource_classifier_config": {"deployment_name": "other"},
}),
default_model=None,
user_api_key_dict=_actor(models=["allowed"] if restricted == "key" else permitted),
team=_team(models=["allowed"] if restricted == "team" else permitted),
prisma_client=_Client(),
llm_router=catalog,
)
if restricted is not None:
with pytest.raises(ProxyException, match="other"):
await operation
return
await operation
Loading
Loading