From 6fb9bd99d55f02ceb81d72c88f36f8d045bf17b9 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:45:54 -0700 Subject: [PATCH 1/6] feat(auto-router): backport decision model creation flow to rc 1.106 --- .../auto_router_permissions.py | 3 +- .../complexity_router/complexity_router.py | 20 +- .../complexity_router/config.py | 12 +- .../complexity_router/jev_classifier.py | 81 ++++- .../router_utils/auto_router_model_naming.py | 10 +- .../test_auto_router_permissions.py | 23 ++ .../complexity_router/test_jev_classifier.py | 125 ++++++++ .../test_auto_router_model_naming.py | 17 ++ .../AutoRouters/AutoRoutersPanel.tsx | 5 +- ...oRouterClassifierTabs.integration.test.tsx | 9 +- .../add_model/AutoRouterClassifierTabs.tsx | 87 +++--- .../add_model/ClassificationMethodConfig.tsx | 10 +- .../add_model/ClassifierPrimarySettings.tsx | 68 +++-- .../ComplexityRouterAdvancedSections.tsx | 7 +- .../add_model/ComplexityRouterConfig.tsx | 99 ++++-- .../add_model/DecisionModelSelect.tsx | 62 ++++ .../JevClassifierConfig.integration.test.tsx | 18 +- .../add_model/JevClassifierConfig.tsx | 146 +++++---- .../add_model/NonReasoningTierToggle.tsx | 2 +- .../components/add_model/RoutingOptions.tsx | 12 +- .../add_auto_router_tab.integration.test.tsx | 216 ++++++++++--- .../add_model/add_auto_router_tab.tsx | 285 +++++++++--------- .../components/add_model/auto_setup.test.ts | 44 ++- .../src/components/add_model/auto_setup.ts | 15 + .../add_model/jev_classifier_config.test.ts | 21 ++ .../add_model/jev_classifier_config.ts | 16 +- .../src/lib/autorouter_presets.test.ts | 18 ++ .../src/lib/autorouter_presets.ts | 24 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 29 files changed, 1071 insertions(+), 386 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/DecisionModelSelect.tsx diff --git a/litellm/proxy/management_helpers/auto_router_permissions.py b/litellm/proxy/management_helpers/auto_router_permissions.py index e74578688c3e..54569117038f 100644 --- a/litellm/proxy/management_helpers/auto_router_permissions.py +++ b/litellm/proxy/management_helpers/auto_router_permissions.py @@ -38,9 +38,9 @@ ComplexityRouterConfigWrite, resolve_complexity_router_config_write, ) +from litellm.types.management_endpoints.auto_router_endpoints 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: @@ -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 diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index a5f00aa7e254..7ef31f7a0a2f 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -123,6 +123,7 @@ HttpJevClassifierClient, JevClassifierClient, JevVerdict, + RouterJevClassifierClient, build_jev_request, jev_classifier_cost, ) @@ -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 @@ -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: diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 36c19e37b8de..992c098bc807 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -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") @@ -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 @@ -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( diff --git a/litellm/router_strategy/complexity_router/jev_classifier.py b/litellm/router_strategy/complexity_router/jev_classifier.py index f68462af8e03..9425b4d35a40 100644 --- a/litellm/router_strategy/complexity_router/jev_classifier.py +++ b/litellm/router_strategy/complexity_router/jev_classifier.py @@ -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 @@ -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, @@ -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 @@ -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 @@ -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, @@ -214,7 +291,7 @@ class JevVerdict(NamedTuple): confidence: float model: str cost: float | None - provider: ClassifierProvider = "typesafe" + provider: str = "typesafe" class _RegistryPricing(LiteLLMBaseModel): diff --git a/litellm/router_utils/auto_router_model_naming.py b/litellm/router_utils/auto_router_model_naming.py index 7ace4094cf03..3d78af94cde7 100644 --- a/litellm/router_utils/auto_router_model_naming.py +++ b/litellm/router_utils/auto_router_model_naming.py @@ -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 () diff --git a/tests/unit/proxy/management_helpers/test_auto_router_permissions.py b/tests/unit/proxy/management_helpers/test_auto_router_permissions.py index b19445c95d9a..ba312ce0d2df 100644 --- a/tests/unit/proxy/management_helpers/test_auto_router_permissions.py +++ b/tests/unit/proxy/management_helpers/test_auto_router_permissions.py @@ -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 diff --git a/tests/unit/router_strategy/complexity_router/test_jev_classifier.py b/tests/unit/router_strategy/complexity_router/test_jev_classifier.py index fea283cbe9cf..02a73df549c6 100644 --- a/tests/unit/router_strategy/complexity_router/test_jev_classifier.py +++ b/tests/unit/router_strategy/complexity_router/test_jev_classifier.py @@ -765,3 +765,128 @@ def respond(request: httpx.Request) -> httpx.Response: assert captured["content_type"] == "application/json" assert captured["body"] == request.model_dump(mode="json") assert response.model == "jev-1.13.0" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", [200, 503]) +async def test_deployed_decision_classifier_uses_registered_connection( + status_code: int, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "decision-deployment", + "litellm_params": { + "model": "typesafe/jev-latest", + "api_key": "deployment-key", + "api_base": "https://decision.test", + }, + } + ], + num_retries=0, + ) + classifier: Final = ComplexityRouter( + "deployed-classifier", + router, + { + "classifier_type": "oss_classifier", + "opensource_classifier_config": {"deployment_name": "decision-deployment"}, + "tiers": {"SIMPLE": "cheap", "MEDIUM": "mid", "COMPLEX": "strong", "REASONING": "top"}, + }, + derive_savings_baseline=False, + ) + with respx.mock(assert_all_called=True) as requests: + route: Final = requests.post("https://decision.test/v1/systemone").mock( + return_value=httpx.Response( + status_code, + json={"model": "jev-latest", "answers": {"tier": _answer().model_dump()}}, + ) + ) + outcome: Final = await classifier.aclassify("What is two plus two?") + assert route.call_count == 1 + assert route.calls[0].request.headers["authorization"] == "Bearer deployment-key" + assert json.loads(route.calls[0].request.content)["model"] == "jev-latest" + assert (outcome.cause == "jev_classifier") is (status_code == 200) + if status_code == 200: + assert outcome.jev_verdict is not None + assert outcome.jev_verdict.label == _answer().choice + + +@pytest.mark.parametrize("deployment_name", ["", " "]) +def test_deployed_decision_classifier_rejects_blank_deployment_name(deployment_name: str) -> None: + with pytest.raises(ValueError, match="deployment_name"): + JevClassifierConfig.model_validate({"deployment_name": deployment_name}) + + +@pytest.mark.parametrize("connection", [{"api_key": "secret"}, {"api_base": "https://override.test"}]) +def test_deployed_decision_classifier_rejects_connection_overrides(connection: Mapping[str, str]) -> None: + with pytest.raises(ValueError, match="deployment connection"): + JevClassifierConfig.model_validate({"deployment_name": "deployed", **connection}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["timeout", "cancelled", "invalid"]) +async def test_deployed_decision_classifier_preserves_failure_handling( + failure: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "decision-deployment", + "litellm_params": { + "model": "typesafe/jev-latest", + "api_key": "test", + "api_base": "https://decision.test", + }, + } + ], + num_retries=0, + ) + classifier: Final = ComplexityRouter( + "decision-router", + router, + { + "classifier_type": "oss_classifier", + "opensource_classifier_config": {"deployment_name": "decision-deployment"}, + "tiers": {"SIMPLE": "cheap", "MEDIUM": "mid", "COMPLEX": "strong", "REASONING": "top"}, + }, + derive_savings_baseline=False, + ) + cancellation_reached: Final = asyncio.Event() + with respx.mock(assert_all_called=failure != "cancelled") as upstream: + route: Final = upstream.post("https://decision.test/v1/systemone") + if failure == "timeout": + route.mock(side_effect=httpx.ReadTimeout("classifier timeout")) + elif failure == "cancelled": + + async def cancel_request(request: httpx.Request) -> httpx.Response: + cancellation_reached.set() + raise asyncio.CancelledError() + + route.mock(side_effect=cancel_request) + else: + route.respond(json={"answers": {"tier": _answer("not-a-tier").model_dump()}}) + if failure == "cancelled": + with pytest.raises(asyncio.CancelledError): + await classifier.aclassify("short question") + assert cancellation_reached.is_set() + return + outcome: Final = await classifier.aclassify("short question") + assert outcome.cause != "jev_classifier" + assert outcome.jev_verdict is None + + +def test_switching_to_deployed_classifier_does_not_copy_legacy_credentials() -> None: + from litellm.router_strategy.complexity_router.config import resolve_complexity_router_config_write + + write: Final = resolve_complexity_router_config_write( + {"classifier_type": "oss_classifier", "opensource_classifier_config": {"deployment_name": "registered"}}, + { + "classifier_type": "oss_classifier", + "opensource_classifier_config": {"api_key": "legacy-secret", "api_base": "https://legacy.test"}, + }, + ) + assert write.effective is not None + assert write.effective["opensource_classifier_config"] == {"deployment_name": "registered"} diff --git a/tests/unit/router_utils/test_auto_router_model_naming.py b/tests/unit/router_utils/test_auto_router_model_naming.py index d7016cbc9ec4..1cfa3aa5c91b 100644 --- a/tests/unit/router_utils/test_auto_router_model_naming.py +++ b/tests/unit/router_utils/test_auto_router_model_naming.py @@ -755,3 +755,20 @@ def test_chained_heuristic_and_judge_prompt_claim_each_active_capability( assert count_capability_routers(({"litellm_params": params},), capability=capability) == ( 1 if capability.key in expected else 0 ) + + +def test_deployed_decision_classifier_is_a_model_access_dependency() -> None: + dependencies: Final = strategy_router_dependencies( + { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "classifier_type": "oss_classifier", + "opensource_classifier_config": {"deployment_name": "decision-deployment"}, + "tiers": {"SIMPLE": "cheap"}, + }, + } + ) + assert tuple((dependency.model_name, dependency.role) for dependency in dependencies) == ( + ("cheap", "tier"), + ("decision-deployment", "classifier"), + ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx index 607b676d3047..6f7e04f92c69 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx @@ -100,7 +100,10 @@ export function AutoRoutersPanel({ {/* The form is long, so the dialog caps its height and scrolls its body rather than growing past the viewport. */} - + Add Auto Router diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx index 0d3e7cfe54b7..3c5d555cff8a 100644 --- a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx @@ -105,7 +105,7 @@ describe("Auto-router classifier selection", () => { llm: "LLM", heuristic_first: "LLM", hybrid: "LLM", - jev: "OSS Classifier", + jev: "Decisions Model", }[classifier_type]; expect(screen.getByRole("radio", { name: new RegExp(`^${family}$`) })).toBeChecked(); fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${family}$`) })); @@ -191,17 +191,14 @@ describe("Auto-router classifier selection", () => { expect(screen.getByRole("button", { name: field })).toHaveTextContent("Used by this router"); }); - it("shows Jev's single Complexity approach without changing saved configuration", () => { + it("omits Jev's redundant routing approach without changing saved configuration", () => { const onChange = vi.fn(); renderWithProviders( Existing settings , ); - expect(screen.getByRole("button", { name: "Routing approach" })).toHaveTextContent("ComplexityUnlimited"); - fireEvent.click(screen.getByRole("button", { name: "Routing approach" })); - expect(screen.getAllByRole("menuitemradio")).toHaveLength(1); - fireEvent.click(screen.getByRole("menuitemradio", { name: /^Complexity/ })); + expect(screen.queryByRole("button", { name: "Routing approach" })).not.toBeInTheDocument(); expect(onChange).not.toHaveBeenCalled(); }); diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx index f21dc3d795aa..509329bb948a 100644 --- a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx @@ -1,7 +1,9 @@ import React, { useContext, useId } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { ChevronDownIcon } from "lucide-react"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { ChevronDownIcon, Info } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -124,9 +126,17 @@ interface AutoRouterClassifierTabsProps { value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; children: React.ReactNode; + judgeModel?: React.ReactNode; + decisionModel?: React.ReactNode; } -const AutoRouterClassifierTabs: React.FC = ({ value, onChange, children }) => { +const AutoRouterClassifierTabs: React.FC = ({ + value, + onChange, + children, + judgeModel, + decisionModel, +}) => { const id = useId(); const availability = useContext(AutoRouterAvailabilityContext); const classifierType = effectiveClassifierType(value); @@ -167,10 +177,17 @@ const AutoRouterClassifierTabs: React.FC = ({ val llm_v2: "Use the efficient model when its predicted quality is close enough to the capable model", }; return ( -
+
- - What classifies your requests? + +
+

Choose your classifier

+ + + +
@@ -179,8 +196,8 @@ const AutoRouterClassifierTabs: React.FC = ({ val { value: "llm", label: "LLM", description: "Use a judge model to choose a solver" }, { value: "jev", - label: "OSS Classifier", - description: "Use Jev, Laya, Bespoke Nimble, or Databricks to choose a tier", + label: "Decisions Model", + description: "Use a decision model to choose a tier", }, ].map((option) => (
- {family === "jev" && ( -
- OSS provider - - - - - - -
- )} + {family === "jev" && + (decisionModel ?? ( +
+ + +
+ ))} {family === "custom" && (

This router uses a custom classifier plugin

)} @@ -268,7 +284,8 @@ const AutoRouterClassifierTabs: React.FC = ({ val

)} - {(family === "llm" || family === "jev") && ( + {family === "llm" && judgeModel} + {family === "llm" && (
= }; interface ClassificationMethodConfigProps { + jevSection?: "all" | "advanced"; value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; modelOptions: { value: string; label: string }[]; @@ -204,6 +205,7 @@ const ClassificationMethodConfig: React.FC = ({ showValidationErrors = false, defaultModel, advancedOnly = false, + jevSection, section, }) => { const [draft, setDraft] = React.useState<{ id: string; raw: string } | null>(null); @@ -522,7 +524,9 @@ const ClassificationMethodConfig: React.FC = ({
)} - {showClassifier && classifierType === "jev" && } + {showClassifier && classifierType === "jev" && ( + + )} {showClassifier && usesLlmClassifier(classifierType) && (
= ({ /> Number of prior user turns sent to the classifier provider, excluding tool output and harness reminders. - LLM and OSS classifiers default to 3 turns. Set to 0 to omit conversation history. The current message and - selected system text are still sent. + LLM and decision model classifiers default to 3 turns. Set to 0 to omit conversation history. The current + message and selected system text are still sent.
diff --git a/ui/litellm-dashboard/src/components/add_model/ClassifierPrimarySettings.tsx b/ui/litellm-dashboard/src/components/add_model/ClassifierPrimarySettings.tsx index 32cb48515808..cf5e7e2db03c 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassifierPrimarySettings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassifierPrimarySettings.tsx @@ -18,11 +18,13 @@ export default function ClassifierPrimarySettings({ onChange, modelOptions, showValidationErrors = false, + section = "all", }: { value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; modelOptions: { value: string; label: string }[]; showValidationErrors?: boolean; + section?: "all" | "judge" | "frequency"; }) { const id = React.useId(); const restriction = restrictedBy(value, "sessionAffinity"); @@ -35,34 +37,44 @@ export default function ClassifierPrimarySettings({ const usesJudge = usesLlmClassifier(effectiveClassifierType(value)); const missingJudge = showValidationErrors && usesJudge && !value.classifier_llm_config?.model; return ( -
-
- - -

{restriction?.reason ?? frequencyDescription}

-
- {usesJudge && ( +
+ {section !== "judge" && ( +
+ + +

{restriction?.reason ?? frequencyDescription}

+
+ )} + {section !== "frequency" && usesJudge && (
void; forecast: boolean; @@ -59,6 +60,7 @@ interface ComplexityRouterAdvancedSectionsProps { } const ComplexityRouterAdvancedSections: React.FC = ({ + hideClassifier, value, onChange, forecast, @@ -268,7 +270,10 @@ const ComplexityRouterAdvancedSections: React.FC sections.some(({ key }) => group.keys.includes(key))); + ].filter( + (group) => + !(hideClassifier && group.keys.includes("classifier")) && sections.some(({ key }) => group.keys.includes(key)), + ); const [openGroups, setOpenGroups] = React.useState(() => showValidationErrors ? groups.map((group) => group.label) : [], ); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 4e8e981fd4a3..72a08c53267d 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -37,7 +37,7 @@ import { } from "./tier_rows"; import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; -import { InactiveHeuristicV2Threshold } from "./ClassificationMethodConfig"; +import ClassificationMethodConfig, { InactiveHeuristicV2Threshold } from "./ClassificationMethodConfig"; import ComplexityRouterAdvancedSections from "./ComplexityRouterAdvancedSections"; import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions"; import { @@ -472,6 +472,8 @@ export const withClassificationFrequency = ( }); interface ComplexityRouterConfigProps { + creationFlow?: boolean; + tierSetup?: React.ReactNode; modelInfo: ModelGroup[]; value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; @@ -548,7 +550,60 @@ export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03; */ export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_ORDER.slice(0, -1); +const isJevCreation = (value: ComplexityRouterConfigValue, creationFlow: boolean | undefined) => + creationFlow && effectiveClassifierType(value) === "jev"; + +const CreationClassifierSettings = ({ + creationFlow, + value, + onChange, + modelOptions, + classifierEffortOptionsByModel, + defaultModel, + showValidationErrors, +}: Pick & { + creationFlow: boolean | undefined; + modelOptions: { value: string; label: string }[]; + classifierEffortOptionsByModel: Record; + defaultModel: string | undefined; + showValidationErrors: boolean; +}) => { + const jevCreation = isJevCreation(value, creationFlow); + return ( + <> + + {jevCreation && ( +
+ +
+ +
+
+
+ )} + + ); +}; + const ComplexityRouterConfig: React.FC = ({ + creationFlow, + tierSetup, modelInfo, value, onChange, @@ -572,6 +627,7 @@ const ComplexityRouterConfig: React.FC = ({ showValidationErrors = false, }) => { const forecast = isForecastClassifier(value.classifier_type); + const tierHeading = creationFlow ? "Define your tiers" : "Models by tier"; const customTierSet = value.custom_tier_set; const tierRows = activeTierRows(value); const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null; @@ -620,19 +676,25 @@ const ComplexityRouterConfig: React.FC = ({ return (
- -
-

{forecast ? "Solver models" : "Models by tier"}

- {!forecast && ( - - - - )} + {creationFlow && } +
+
+

{forecast ? "Solver models" : tierHeading}

+ {!forecast && ( + + + + )} +
@@ -657,16 +719,9 @@ const ComplexityRouterConfig: React.FC = ({ ) : ( <> + {tierSetup} - {!customTierSet && ( - - )} - {tierRows.map((row, index) => { const tierInfo = builtInTierInfo(row.id); const label = tierRowLabel(row, value.tier_labels); @@ -756,6 +811,14 @@ const ComplexityRouterConfig: React.FC = ({ ); })} + {!customTierSet && ( + + )} + = ({ )} - = ({ )}
void; + modelInfo: ModelGroup[]; + deployments: AutoRouterDeployment[] | undefined; +}) { + const id = React.useId(); + const { data: modelCostMapData } = useModelCostMap(true, true); + const decisionCatalog = React.useMemo(() => buildDecisionCatalog(modelCostMapData), [modelCostMapData]); + const decisionModels = modelInfo.filter( + (model) => + isDecisionMode(model.mode) || + (deployments ?? []).some( + (deployment) => + deployment.model_name === model.model_group && + isDecisionSelection( + decisionCatalog, + deployment.litellm_params?.custom_llm_provider ?? deployment.litellm_params?.model?.split("/")[0], + [deployment.litellm_params?.model ?? "", deployment.litellm_params?.base_model ?? ""], + ), + ), + ); + + return ( +
+ + ({ value: model.model_group, label: model.model_group }))} + value={value.jev_classifier_config?.deployment_name ?? ""} + placeholder="Select the decision model" + allowClear={false} + onValueChange={(deploymentName) => { + if (!deploymentName) return; + onChange({ + ...value, + jev_classifier_config: { + ...(value.jev_classifier_config ?? defaultJevClassifierConfig()), + deployment_name: deploymentName, + }, + }); + }} + /> +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx index 15a736de7b29..dab0b010febf 100644 --- a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx @@ -111,19 +111,23 @@ describe("JEV classifier editor", () => { expect(screen.getByText("Reasoning Effort")).toBeInTheDocument(); expect(screen.getByText("Classifier Prompt")).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("radio", { name: /^OSS Classifier$/ })); - expect(screen.getByRole("radio", { name: /^OSS Classifier$/ })).toBeChecked(); + fireEvent.click(screen.getByRole("radio", { name: /^Decisions Model$/ })); + expect(screen.getByRole("radio", { name: /^Decisions Model$/ })).toBeChecked(); expect(screen.getByLabelText("Classifier Model")).toHaveValue("jev-latest"); expect(screen.getByLabelText("Classifier Instructions")).toBeEnabled(); expect(screen.queryByLabelText("Judge model")).not.toBeInTheDocument(); expect(screen.queryByText("Reasoning Effort")).not.toBeInTheDocument(); expect(screen.queryByText("Classifier Prompt")).not.toBeInTheDocument(); expect(screen.queryByRole("switch", { name: "Use images for classification" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("radio", { name: "Laya" })); + await chooseSelectOption( + userEvent, + screen.getByRole("combobox", { name: "Decision model", exact: true }), + "Laya", + ); expect(screen.getByLabelText("Classifier Model")).toHaveTextContent("english"); - fireEvent.click(screen.getByRole("radio", { name: "Jev" })); + await chooseSelectOption(userEvent, screen.getByRole("combobox", { name: "Decision model", exact: true }), "Jev"); expect(screen.getByLabelText("Classifier Model")).toHaveValue("jev-latest"); - fireEvent.click(screen.getByRole("radio", { name: label })); + await chooseSelectOption(userEvent, screen.getByRole("combobox", { name: "Decision model", exact: true }), label); if (provider === "bespoke") expect(screen.getByLabelText("Classifier Model")).toHaveTextContent("nimble-latest"); if (provider === "databricks") expect(screen.getByLabelText("Classifier Model")).toHaveValue(""); if (typesTheModel) { @@ -137,8 +141,8 @@ describe("JEV classifier editor", () => { fireEvent.click(screen.getByRole("switch", { name: "Classifier circuit breaker" })); fireEvent.click(screen.getByRole("button", { name: "Customize tiers" })); fireEvent.click(screen.getByRole("button", { name: "Save and reload" })); - expect(screen.getByRole("radio", { name: /^OSS Classifier$/ })).toBeChecked(); - expect(screen.getByRole("radio", { name: label })).toBeChecked(); + expect(screen.getByRole("radio", { name: /^Decisions Model$/ })).toBeChecked(); + expect(screen.getByRole("combobox", { name: "Decision model", exact: true })).toHaveTextContent(label); if (typesTheModel) expect(screen.getByLabelText("Classifier Model")).toHaveValue(model); else expect(screen.getByLabelText("Classifier Model")).toHaveTextContent(model); expect(screen.getByLabelText("Classifier Timeout (ms)")).toHaveValue(4200); diff --git a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.tsx b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.tsx index 86d2d0f83483..3d604e7a7323 100644 --- a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.tsx @@ -21,9 +21,11 @@ const providerDescriptions = { export default function JevClassifierConfig({ value, onChange, + section = "all", }: { value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; + section?: "all" | "model" | "advanced"; }) { const id = useId(); const config = value.jev_classifier_config ?? defaultJevClassifierConfig(); @@ -32,75 +34,85 @@ export default function JevClassifierConfig({ onChange({ ...value, jev_classifier_config: { ...config, ...patch } }); return ( -
-

{providerDescriptions[config.provider ?? "jev"]}

-
- - {models ? ( - - ) : ( - + {section !== "advanced" && ( + <> +

{providerDescriptions[config.provider ?? "jev"]}

+
+ + {models ? ( + + ) : ( + update({ model: event.target.value })} + /> + )} +
+ + )} + {section !== "model" && ( + <> +
+ + update({ timeout_ms: Number(event.target.value) })} + /> +
+ + update({ + circuit_breaker_enabled: next.circuit_breaker_enabled, + circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds, + }) } - onChange={(event) => update({ model: event.target.value })} /> - )} -
-
- - update({ timeout_ms: Number(event.target.value) })} - /> -
- - update({ - circuit_breaker_enabled: next.circuit_breaker_enabled, - circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds, - }) - } - /> -
- - -