chore: sync SSO repair with main

This commit is contained in:
Yuneng Jiang 2026-10-10 21:06:57 -07:00
commit 400e46f586
No known key found for this signature in database
42 changed files with 1693 additions and 535 deletions

View file

@ -440,6 +440,10 @@ autorouter_presets_url: str = os.getenv(
"LITELLM_AUTOROUTER_PRESETS_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/autorouter_presets.json",
)
whats_new_url: str = os.getenv(
"LITELLM_WHATS_NEW_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/whats_new.json",
)
suppress_debug_info: bool = False
dynamodb_table_name: Optional[str] = None
s3_callback_params: Optional[Dict] = None

View file

@ -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

View file

@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
from importlib.resources import files
from typing import TYPE_CHECKING, Final, Protocol
import httpx
from fastapi import APIRouter, HTTPException, Request
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
import litellm
@ -37,6 +38,7 @@ from litellm.types.proxy.public_endpoints.public_endpoints import (
ProviderCreateInfo,
PublicModelHubInfo,
SupportedEndpointsResponse,
WhatsNewResponse,
)
from litellm.types.utils import LlmProviders
@ -505,14 +507,18 @@ def _load_bundled_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]:
)
async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
async def _fetch_remote_bytes(url: str) -> bytes:
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.UI)
response: Final = await client.get(url, timeout=5.0)
response.raise_for_status()
presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_python(response.json())
return response.content
async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_json(await _fetch_remote_bytes(url))
if not presets:
raise ValueError("remote auto-router preset catalog is empty")
return presets
@ -575,6 +581,70 @@ async def get_public_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord
return await get_autorouter_presets(url=litellm.autorouter_presets_url)
def _load_bundled_whats_new() -> WhatsNewResponse:
return WhatsNewResponse.model_validate_json(
files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8")
)
async def _fetch_remote_whats_new(url: str) -> WhatsNewResponse:
return WhatsNewResponse.model_validate_json(await _fetch_remote_bytes(url))
async def _resolve_whats_new(url: str, fetch: Callable[[str], Awaitable[WhatsNewResponse]]) -> WhatsNewResponse:
if os.getenv("LITELLM_LOCAL_WHATS_NEW", "").lower() == "true":
return _load_bundled_whats_new()
try:
return await fetch(url)
except (httpx.HTTPError, ValidationError, OSError) as e:
verbose_logger.warning(
"LiteLLM: failed to fetch What's new launches from %s: %s. Serving the bundled list for the life of this process.",
url,
str(e),
)
return _load_bundled_whats_new()
class _WhatsNewCache:
launches: WhatsNewResponse | None = None
lock: asyncio.Lock | None = None
async def get_whats_new(
url: str,
fetch: Callable[[str], Awaitable[WhatsNewResponse]] = _fetch_remote_whats_new,
) -> WhatsNewResponse:
cached: Final = _WhatsNewCache.launches
if cached is not None:
return cached
if _WhatsNewCache.lock is None:
_WhatsNewCache.lock = asyncio.Lock()
async with _WhatsNewCache.lock:
held: Final = _WhatsNewCache.launches
if held is not None:
return held
resolved: Final = await _resolve_whats_new(url=url, fetch=fetch)
_WhatsNewCache.launches = resolved
return resolved
@router.get(
"/public/whats_new",
tags=["public"],
response_model=WhatsNewResponse,
)
async def get_public_whats_new() -> WhatsNewResponse:
"""
Return the launches the dashboard Home page shows under What's new.
Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``)
on the first request, falling back to the list bundled with the package on any failure, so an airgapped
proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch.
A restart picks up a newly published list.
"""
return await get_whats_new(url=litellm.whats_new_url)
@router.get(
"/public/endpoints",
tags=["public"],

View file

@ -0,0 +1,25 @@
{
"launches": [
{
"icon": "box",
"title": "Claude Haiku 5.5",
"description": "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.",
"href": "https://docs.litellm.ai/blog/claude-haiku-5-5",
"published_on": "2026-10-07"
},
{
"icon": "scale",
"title": "Decision Models",
"description": "Call decision models at /v1/decisions and try them in the Decisions playground.",
"href": "https://docs.litellm.ai/docs/decisions",
"published_on": "2026-10-07"
},
{
"icon": "zap",
"title": "OpenAI Ultrafast",
"description": "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.",
"href": "https://docs.litellm.ai/docs/providers/openai/ultrafast",
"published_on": "2026-10-06"
}
]
}

View file

@ -123,6 +123,7 @@ from .jev_classifier import (
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 @@ class ComplexityRouter(CustomLogger):
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:

View file

@ -716,6 +716,7 @@ _ENVIRONMENT_KEY_SCOPE: Final = MappingProxyType(
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 @@ class OpenSourceClassifierConfig(LiteLLMBaseModel):
@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(

View file

@ -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 @@ from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
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,
@ -22,9 +23,13 @@ from litellm.llms.databricks.decisions.transformation import DATABRICKS_DECISION
from litellm.llms.laya.common_utils import laya_response_model
from litellm.llms.pass_through.typesafe_logging_handler import 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
@ -67,6 +72,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
@ -79,6 +86,76 @@ class JevClassifierClient(Protocol):
) -> 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,
@ -212,7 +289,7 @@ class JevVerdict(NamedTuple):
confidence: float
model: str
cost: float | None
provider: ClassifierProvider = "typesafe"
provider: str = "typesafe"
class _RegistryPricing(LiteLLMBaseModel):

View file

@ -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 ()

View file

@ -1,4 +1,5 @@
from collections.abc import Mapping, Sequence
from datetime import date
from typing import Any, Literal
from pydantic import ConfigDict
@ -113,6 +114,24 @@ class AutoRouterPresetRecord(LiteLLMBaseModel):
complexity_router_config: AutoRouterPresetConfig
class WhatsNewLaunch(LiteLLMBaseModel):
"""One launch card in the dashboard Home page's What's new section."""
model_config = ConfigDict(frozen=True)
icon: str
title: str
description: str
href: str
published_on: date
class WhatsNewResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
launches: tuple[WhatsNewLaunch, ...]
class ComplexityScorerDefaults(LiteLLMBaseModel):
"""The complexity router's shipped heuristic scorer defaults.

View file

@ -321,6 +321,7 @@ include = [
"litellm/router_strategy/complexity_router/artifacts/*.json",
"litellm/router_strategy/complexity_router/fuse_presets.json",
"litellm/proxy/model_insights_tasks.json",
"litellm/proxy/public_endpoints/whats_new.json",
"litellm/proxy/common_utils/codex_base_instructions.md",
"litellm/proxy/common_utils/codex_bundled_models_0.159.3.json",
]

View file

@ -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

View file

@ -1,6 +1,8 @@
import json
import re
from datetime import datetime, timezone
from importlib.resources import files
from collections.abc import Iterator
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -17,7 +19,7 @@ from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_pres
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelGroupInfoProxy,
)
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo, WhatsNewResponse
from litellm.types.utils import LlmProviders
@ -67,13 +69,10 @@ def test_get_provider_create_fields():
assert isinstance(first_provider["credential_fields"], list)
has_detailed_fields = any(
provider.get("credential_fields")
and len(provider.get("credential_fields", [])) > 0
provider.get("credential_fields") and len(provider.get("credential_fields", [])) > 0
for provider in response_data
)
assert (
has_detailed_fields
), "Expected at least one provider to have detailed credential fields"
assert has_detailed_fields, "Expected at least one provider to have detailed credential fields"
def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries(
@ -124,10 +123,7 @@ def test_get_litellm_model_cost_map_returns_cost_map():
sample_model_data = payload[sample_model]
assert isinstance(sample_model_data, dict)
# Check for common cost fields that should be present
assert (
"input_cost_per_token" in sample_model_data
or "output_cost_per_token" in sample_model_data
)
assert "input_cost_per_token" in sample_model_data or "output_cost_per_token" in sample_model_data
def test_public_ai_hub_info_is_public_by_default(monkeypatch):
@ -218,9 +214,9 @@ def test_anthropic_provider_fields_support_byok():
"Anthropic api_key must be optional so admins can configure BYOK models "
"without entering a key. See BYOK tutorial."
)
assert fields_by_key["api_key"].get(
"tooltip"
), "Anthropic api_key must have a tooltip explaining the BYOK use case."
assert fields_by_key["api_key"].get("tooltip"), (
"Anthropic api_key must have a tooltip explaining the BYOK use case."
)
assert "api_base" in fields_by_key, (
"Anthropic provider form must expose api_base so cloud customers "
"can override the upstream URL without env var access."
@ -228,16 +224,14 @@ def test_anthropic_provider_fields_support_byok():
api_base_field = fields_by_key["api_base"]
assert api_base_field["required"] is False
assert api_base_field["field_type"] == "text"
assert api_base_field.get(
"tooltip"
), "api_base should have a tooltip explaining it is optional."
assert api_base_field.get("tooltip"), "api_base should have a tooltip explaining it is optional."
# UI forms render fields in credential_fields order; api_base should come first
# so an admin sees the URL override before the key field.
field_order = [f["key"] for f in anthropic["credential_fields"]]
assert field_order.index("api_base") < field_order.index(
"api_key"
), "api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
assert field_order.index("api_base") < field_order.index("api_key"), (
"api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
)
def test_bedrock_mantle_provider_fields():
@ -554,9 +548,7 @@ def test_google_ai_studio_provider_fields_expose_api_base():
assert response.status_code == 200
providers = response.json()
google_ai = next(
(p for p in providers if p["provider"] == "Google_AI_Studio"), None
)
google_ai = next((p for p in providers if p["provider"] == "Google_AI_Studio"), None)
assert google_ai is not None, "Google_AI_Studio provider entry not found"
assert google_ai["litellm_provider"] == "gemini"
@ -576,18 +568,15 @@ def test_google_ai_studio_provider_fields_expose_api_base():
# placeholder shows the canonical URL so users still get the visual hint.
# (See greptileai threads on PR #30419.)
assert api_base_field["default_value"] is None
assert (
api_base_field["placeholder"]
== "https://generativelanguage.googleapis.com/v1beta"
)
assert api_base_field["placeholder"] == "https://generativelanguage.googleapis.com/v1beta"
# UI forms render fields in credential_fields order; api_base should come
# first so an admin sees the URL override before the key field (matches
# OpenAI and Anthropic conventions).
field_order = [f["key"] for f in google_ai["credential_fields"]]
assert field_order.index("api_base") < field_order.index(
"api_key"
), "api_base must appear before api_key in credential_fields."
assert field_order.index("api_base") < field_order.index("api_key"), (
"api_base must appear before api_key in credential_fields."
)
def test_public_model_hub_with_healthy_model():
@ -615,20 +604,15 @@ def test_public_model_hub_with_healthy_model():
mock_llm_router = MagicMock()
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(
return_value=[mock_health_check]
)
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
with (
patch("litellm.public_model_groups", ["gpt-3.5-turbo"]),
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch(
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
) as mock_convert,
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
):
mock_get_info.return_value = [mock_model_group]
mock_convert.return_value = {
"status": "healthy",
@ -673,20 +657,15 @@ def test_public_model_hub_with_unhealthy_model():
mock_llm_router = MagicMock()
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(
return_value=[mock_health_check]
)
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
with (
patch("litellm.public_model_groups", ["gpt-4"]),
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch(
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
) as mock_convert,
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
):
mock_get_info.return_value = [mock_model_group]
mock_convert.return_value = {
"status": "unhealthy",
@ -732,7 +711,6 @@ def test_public_model_hub_without_health_check():
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
):
mock_get_info.return_value = [mock_model_group]
response = client.get(
@ -789,9 +767,7 @@ def test_public_model_hub_mixed_health_statuses():
mock_llm_router = MagicMock()
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(
return_value=[healthy_check, unhealthy_check]
)
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[healthy_check, unhealthy_check])
def convert_side_effect(check):
if check.model_name == "gpt-3.5-turbo":
@ -813,11 +789,8 @@ def test_public_model_hub_mixed_health_statuses():
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch(
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
) as mock_convert,
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
):
mock_get_info.return_value = [
healthy_model,
unhealthy_model,
@ -1066,9 +1039,7 @@ def test_get_supported_endpoints_provider_fields(reset_endpoints_cache):
def test_get_supported_endpoints_paths_start_with_slash(reset_endpoints_cache):
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
for item in endpoints:
assert item["endpoint"].startswith(
"/"
), f"Expected path starting with /, got: {item['endpoint']}"
assert item["endpoint"].startswith("/"), f"Expected path starting with /, got: {item['endpoint']}"
def test_get_supported_endpoints_chat_completions_present(reset_endpoints_cache):
@ -1092,9 +1063,9 @@ def test_get_supported_endpoints_display_names_have_no_slug_suffix(
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
for item in endpoints:
for provider in item["providers"]:
assert not suffix_re.search(
provider["display_name"]
), f"display_name still contains slug suffix: {provider['display_name']!r}"
assert not suffix_re.search(provider["display_name"]), (
f"display_name still contains slug suffix: {provider['display_name']!r}"
)
def test_get_supported_endpoints_is_cached(reset_endpoints_cache):
@ -1320,7 +1291,6 @@ def test_public_mcp_hub_does_not_expose_upstream_url():
app.dependency_overrides.clear()
@pytest.fixture
def reset_autorouter_presets_cache():
from litellm.proxy.public_endpoints.public_endpoints import _AutoRouterPresetsCache
@ -1332,9 +1302,7 @@ def reset_autorouter_presets_cache():
_AutoRouterPresetsCache.lock = None
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(
monkeypatch, reset_autorouter_presets_cache
):
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(monkeypatch, reset_autorouter_presets_cache):
monkeypatch.setenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "True")
app = FastAPI()
app.include_router(router)
@ -1362,9 +1330,7 @@ def test_get_autorouter_presets_local_mode_serves_bundled_catalog(
@pytest.mark.asyncio
async def test_get_autorouter_presets_fetches_once_per_process(
monkeypatch, reset_autorouter_presets_cache
):
async def test_get_autorouter_presets_fetches_once_per_process(monkeypatch, reset_autorouter_presets_cache):
from litellm.proxy.public_endpoints.public_endpoints import (
_AUTOROUTER_PRESETS_ADAPTER,
get_autorouter_presets,
@ -1376,7 +1342,9 @@ async def test_get_autorouter_presets_fetches_once_per_process(
"remote_only": {
"label": "Remote Only",
"description": "from the remote catalog",
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
"complexity_router_config": {
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
},
}
}
)
@ -1411,7 +1379,9 @@ async def test_get_autorouter_presets_single_flight_on_concurrent_cold_start(
"remote_only": {
"label": "Remote Only",
"description": "from the remote catalog",
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
"complexity_router_config": {
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
},
}
}
)
@ -1507,9 +1477,7 @@ async def test_autorouter_presets_adapter_rejects_wrong_shapes():
)
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(
monkeypatch, reset_autorouter_presets_cache
):
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(monkeypatch, reset_autorouter_presets_cache):
from litellm.proxy.public_endpoints.public_endpoints import (
_AUTOROUTER_PRESETS_ADAPTER,
_AutoRouterPresetsCache,
@ -1551,12 +1519,14 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
"remote_only": {
"label": "Remote Only",
"description": "from the remote catalog",
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
"complexity_router_config": {
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
},
}
}
response = MagicMock()
response.raise_for_status = MagicMock()
response.json = MagicMock(return_value=catalog)
response.content = json.dumps(catalog).encode()
client = MagicMock()
client.get = AsyncMock(return_value=response)
monkeypatch.setattr(http_handler_module, "get_async_httpx_client", lambda llm_provider: client)
@ -1565,6 +1535,121 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
assert presets["remote_only"].label == "Remote Only"
response.raise_for_status.assert_called_once()
response.json = MagicMock(return_value={})
response.content = b"{}"
with pytest.raises(ValueError, match="empty"):
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
@pytest.fixture
def reset_whats_new_cache() -> Iterator[None]:
from litellm.proxy.public_endpoints.public_endpoints import _WhatsNewCache
_WhatsNewCache.launches = None
_WhatsNewCache.lock = None
yield
_WhatsNewCache.launches = None
_WhatsNewCache.lock = None
def _remote_whats_new() -> WhatsNewResponse:
return WhatsNewResponse.model_validate(
{
"launches": [
{
"icon": "sparkles",
"title": "Remote Launch",
"description": "from the remote list",
"href": "https://docs.litellm.ai/blog/remote",
"published_on": "2026-10-11",
}
]
}
)
def test_get_whats_new_local_mode_serves_bundled_list(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
monkeypatch.setenv("LITELLM_LOCAL_WHATS_NEW", "True")
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.get("/public/whats_new")
assert response.status_code == 200
served: Final = WhatsNewResponse.model_validate_json(response.content)
bundled: Final = WhatsNewResponse.model_validate_json(
files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8")
)
assert served == bundled
assert len(served.launches) > 0
@pytest.mark.asyncio
async def test_get_whats_new_fetches_once_per_process(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
from litellm.proxy.public_endpoints.public_endpoints import get_whats_new
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
remote = _remote_whats_new()
calls: Final[list[str]] = []
async def fake_fetch(url: str) -> WhatsNewResponse:
calls.append(url)
return remote
first = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch)
second = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch)
assert first == remote
assert second == remote
assert calls == ["https://example.test/whats_new.json"]
@pytest.mark.asyncio
async def test_get_whats_new_caches_bundled_fallback_when_remote_is_unreachable(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
from litellm.proxy.public_endpoints.public_endpoints import _load_bundled_whats_new, get_whats_new
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
calls: Final[list[str]] = []
async def unreachable_fetch(url: str) -> WhatsNewResponse:
calls.append(url)
raise OSError("network is unreachable")
first = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch)
second = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch)
assert first == _load_bundled_whats_new()
assert second == first
assert len(calls) == 1
@pytest.mark.asyncio
async def test_fetch_remote_whats_new_rejects_a_malformed_list_so_the_bundled_one_is_served(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
import httpx
from litellm.proxy.public_endpoints import public_endpoints
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
request = httpx.Request("GET", "https://example.test/whats_new.json")
client = MagicMock()
client.get = AsyncMock(
return_value=httpx.Response(
200,
request=request,
content=b'{"launches": [{"icon": "box", "title": "No date", "description": "d", "href": "https://x"}]}',
)
)
with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=client):
served = await public_endpoints.get_whats_new(url="https://example.test/whats_new.json")
assert served == public_endpoints._load_bundled_whats_new()
client.get.assert_awaited_once_with("https://example.test/whats_new.json", timeout=5.0)

View file

@ -573,7 +573,11 @@ async def test_databricks_routes_through_the_serving_endpoint_and_accounts_the_e
"jev_classifier_config" if legacy else "opensource_classifier_config": {
"provider": "databricks",
"model": endpoint,
**({"api_key": "dapi-configured", "api_base": "https://workspace.test/serving-endpoints"} if configured_key else {}),
**(
{"api_key": "dapi-configured", "api_base": "https://workspace.test/serving-endpoints"}
if configured_key
else {}
),
},
"tiers": {"SIMPLE": "cheap"},
},
@ -596,7 +600,9 @@ async def test_databricks_routes_through_the_serving_endpoint_and_accounts_the_e
assert (outcome.jev_verdict.provider, outcome.jev_verdict.model) == ("databricks", endpoint)
assert outcome.classifier_cost == pytest.approx(0.31)
sent: Final = route.calls.last.request
assert sent.headers.get("authorization") == ("Bearer dapi-configured" if configured_key else "Bearer dapi-env-token")
assert sent.headers.get("authorization") == (
"Bearer dapi-configured" if configured_key else "Bearer dapi-env-token"
)
assert json.loads(sent.content)["model"] == endpoint
assert len(recorder.calls) == 1
assert recorder.calls[0]["response_cost"] == pytest.approx(0.31)
@ -765,3 +771,143 @@ async def test_http_jev_classifier_client_posts_to_system_one() -> None:
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])
@pytest.mark.parametrize("provider,model", [("typesafe", "jev-latest"), ("databricks", "my-endpoint")])
async def test_deployed_decision_classifier_uses_registered_connection(
status_code: int, provider: str, model: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setitem(litellm.model_cost, f"{provider}/{model}", {"input_cost_per_token": 0.01})
router: Final = litellm.Router(
model_list=[
{
"model_name": "decision-deployment",
"litellm_params": {
"model": f"{provider}/{model}",
"api_key": "deployment-key",
"api_base": "https://decision.test/serving-endpoints"
if provider == "databricks"
else "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/serving-endpoints/my-endpoint/invocations"
if provider == "databricks"
else "https://decision.test/v1/systemone"
).mock(
return_value=httpx.Response(
status_code,
json={
"model": model,
"answers": {"tier": _answer().model_dump()},
"usage": {"input_tokens": 31, "output_tokens": 0},
},
)
)
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"] == model
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
assert outcome.jev_verdict.provider == provider
assert outcome.classifier_cost == pytest.approx(0.31)
@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"}

View file

@ -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"),
)

View file

@ -6,7 +6,6 @@ import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dai
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import { all_admin_roles } from "@/utils/roles";
import HomePage from "./HomePage";
import { WHATS_NEW_ITEMS } from "./homeContent";
const auth = vi.hoisted(() => ({
accessToken: "sk-test",
@ -31,6 +30,28 @@ vi.mock("@/components/networking", async (importOriginal) => ({
const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] };
const launch = (title: string, icon: string, published_on: string) => ({
icon,
title,
description: `${title} description`,
href: `https://docs.litellm.ai/blog/${title}`,
published_on,
});
const whatsNew = vi.hoisted(() => ({ launches: [] as unknown[] }));
const requestUrl = (input: RequestInfo | URL): string => {
if (typeof input === "string") return input;
return input instanceof URL ? input.href : input.url;
};
const fakeFetch = (input: RequestInfo | URL) => {
const body = requestUrl(input).endsWith("/public/whats_new") ? whatsNew : blogPosts;
return Promise.resolve(
new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
);
};
const dayResult = (date: string, model: string, tokens: number) => {
const metrics = {
spend: 1,
@ -86,7 +107,8 @@ describe("HomePage", () => {
network.aggregated.mockReset().mockResolvedValue(activity);
network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 });
network.userInfo.mockReset().mockResolvedValue({ user_info: { max_budget: null } });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(blogPosts), { status: 200 })));
whatsNew.launches = [launch("mid", "box", "2026-10-06"), launch("new", "zap", "2026-10-11")];
vi.stubGlobal("fetch", vi.fn(fakeFetch));
});
afterEach(() => {
vi.unstubAllGlobals();
@ -121,13 +143,32 @@ describe("HomePage", () => {
expect(network.aggregated).toHaveBeenCalledTimes(2);
});
it("lists the curated launches newest first with their links", () => {
it("renders the launches served by /public/whats_new newest first, whatever order the JSON lists them in", async () => {
whatsNew.launches = [
launch("mid", "box", "2026-10-06"),
launch("new", "an-icon-from-a-newer-list", "2026-10-11"),
launch("old", "scale", "2026-09-30"),
];
renderHome();
const section = screen.getByTestId("home-whats-new");
const links = within(section).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(WHATS_NEW_ITEMS.map((item) => item.href));
const dates = WHATS_NEW_ITEMS.map((item) => item.publishedOn);
expect(dates).toEqual([...dates].sort().reverse());
const section = await screen.findByTestId("home-whats-new");
expect(await within(section).findByText("new")).toBeInTheDocument();
expect(
within(section)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"https://docs.litellm.ai/blog/new",
"https://docs.litellm.ai/blog/mid",
"https://docs.litellm.ai/blog/old",
]);
expect(within(section).getByText("Oct 11")).toBeInTheDocument();
});
it("hides What's new when /public/whats_new has no launches", async () => {
whatsNew.launches = [];
renderHome();
expect(await screen.findByRole("link", { name: /post one/i })).toBeInTheDocument();
expect(screen.queryByTestId("home-whats-new")).not.toBeInTheDocument();
});
it("ranks model groups by token share on the leaderboard", async () => {

View file

@ -4,21 +4,24 @@ import { ArrowRight, Plus, Sparkles } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Page } from "@/components/shared/Page";
import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader";
import { uiHref } from "@/utils/uiHref";
import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
import { formatPublishedOn, WHATS_NEW_ITEMS } from "./homeContent";
import { formatPublishedOn, launchIcon } from "./homeContent";
import UpdatesFeed from "./UpdatesFeed";
import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage";
import { useWhatsNew } from "./useWhatsNew";
const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const;
export default function HomePage() {
const { isViewOnly } = useAuthorized();
const usage = useHomeUsage();
const whatsNew = useWhatsNew();
const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8);
return (
@ -56,34 +59,40 @@ export default function HomePage() {
<div className="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(16rem,1fr)]">
<div className="flex min-w-0 flex-col gap-6">
<section aria-labelledby="home-whats-new" data-testid="home-whats-new" className="flex flex-col gap-3">
<div>
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
<Sparkles className="size-4 text-muted-foreground" />
What&apos;s new in LiteLLM?
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{WHATS_NEW_ITEMS.map((item) => {
const Icon = item.icon;
return (
<a key={item.title} href={item.href} target="_blank" rel="noopener noreferrer" className="group">
<Card className="h-full gap-3 px-4 py-4 transition-colors hover:bg-muted/40">
<div className="flex size-9 items-center justify-center rounded-md bg-muted">
<Icon className="size-4" />
</div>
<div className="text-base font-semibold leading-tight">{item.title}</div>
<p className="m-0 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
<div className="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>{formatPublishedOn(item.publishedOn)}</span>
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
</Card>
</a>
);
})}
</div>
</section>
{(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 0) && (
<section aria-labelledby="home-whats-new" data-testid="home-whats-new" className="flex flex-col gap-3">
<div>
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
<Sparkles className="size-4 text-muted-foreground" />
What&apos;s new in LiteLLM?
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{whatsNew.isLoading &&
[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-40 rounded-xl" data-testid="home-whats-new-loading" />
))}
{whatsNew.data?.map((item) => {
const Icon = launchIcon(item.icon);
return (
<a key={item.title} href={item.href} target="_blank" rel="noopener noreferrer" className="group">
<Card className="h-full gap-3 px-4 py-4 transition-colors hover:bg-muted/40">
<div className="flex size-9 items-center justify-center rounded-md bg-muted">
<Icon className="size-4" />
</div>
<div className="text-base font-semibold leading-tight">{item.title}</div>
<p className="m-0 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
<div className="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>{formatPublishedOn(item.published_on)}</span>
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
</Card>
</a>
);
})}
</div>
</section>
)}
<Panel
title="Leaderboard"

View file

@ -0,0 +1,40 @@
import { Box, Sparkles } from "lucide-react";
import { describe, expect, it } from "vitest";
import { latestLaunches, launchIcon, type WhatsNewLaunch } from "./homeContent";
const launch = (title: string, published_on: string): WhatsNewLaunch => ({
icon: "box",
title,
description: "d",
href: `https://docs.litellm.ai/${title}`,
published_on,
});
describe("latestLaunches", () => {
it("orders launches by published_on, newest first, without mutating the input", () => {
const launches = [launch("old", "2026-09-01"), launch("new", "2026-10-11"), launch("mid", "2026-10-01")];
expect(latestLaunches(launches).map((l) => l.title)).toEqual(["new", "mid", "old"]);
expect(launches.map((l) => l.title)).toEqual(["old", "new", "mid"]);
});
it("keeps only the three newest so the cards fill one row", () => {
const launches = [
launch("oldest", "2026-08-01"),
launch("a", "2026-10-03"),
launch("b", "2026-10-02"),
launch("c", "2026-10-01"),
];
expect(latestLaunches(launches).map((l) => l.title)).toEqual(["a", "b", "c"]);
});
});
describe("launchIcon", () => {
it("maps a known icon name to its lucide icon", () => {
expect(launchIcon("box")).toBe(Box);
});
it("falls back to Sparkles for an icon name this dashboard version does not know", () => {
expect(launchIcon("hologram")).toBe(Sparkles);
expect(launchIcon("constructor")).toBe(Sparkles);
});
});

View file

@ -1,40 +1,26 @@
import { Box, Scale, Zap, type LucideIcon } from "lucide-react";
import { Bot, Box, Gauge, Plug, Rocket, Scale, Shield, Sparkles, Zap, type LucideIcon } from "lucide-react";
import type { components } from "@/lib/http/schema";
export interface Launch {
readonly icon: LucideIcon;
readonly title: string;
readonly description: string;
readonly href: string;
readonly publishedOn: string;
}
export type WhatsNewLaunch = components["schemas"]["WhatsNewLaunch"];
const LAUNCHES: readonly Launch[] = [
{
icon: Box,
title: "Claude Haiku 5.5",
description: "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.",
href: "https://docs.litellm.ai/blog/claude-haiku-5-5",
publishedOn: "2026-10-07",
},
{
icon: Scale,
title: "Decision Models",
description: "Call decision models at /v1/decisions and try them in the Decisions playground.",
href: "https://docs.litellm.ai/docs/decisions",
publishedOn: "2026-10-07",
},
{
icon: Zap,
title: "OpenAI Ultrafast",
description: "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.",
href: "https://docs.litellm.ai/docs/providers/openai/ultrafast",
publishedOn: "2026-10-06",
},
];
const LAUNCH_ICONS: ReadonlyMap<string, LucideIcon> = new Map([
["bot", Bot],
["box", Box],
["gauge", Gauge],
["plug", Plug],
["rocket", Rocket],
["scale", Scale],
["shield", Shield],
["sparkles", Sparkles],
["zap", Zap],
]);
export const WHATS_NEW_ITEMS: readonly Launch[] = [...LAUNCHES].sort((a, b) =>
b.publishedOn.localeCompare(a.publishedOn),
);
export const launchIcon = (name: string): LucideIcon => LAUNCH_ICONS.get(name) ?? Sparkles;
export const WHATS_NEW_MAX = 3;
export const latestLaunches = (launches: readonly WhatsNewLaunch[]): readonly WhatsNewLaunch[] =>
[...launches].sort((a, b) => b.published_on.localeCompare(a.published_on)).slice(0, WHATS_NEW_MAX);
export const formatPublishedOn = (isoDate: string): string =>
new Date(`${isoDate}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });

View file

@ -0,0 +1,10 @@
import { $api } from "@/lib/http/api";
import { latestLaunches } from "./homeContent";
export const useWhatsNew = () =>
$api.useQuery(
"get",
"/public/whats_new",
{},
{ staleTime: 60 * 60 * 1000, select: (data) => latestLaunches(data.launches) },
);

View file

@ -100,7 +100,10 @@ export function AutoRoutersPanel({
<Dialog open={isCreating} onOpenChange={setIsCreating}>
{/* The form is long, so the dialog caps its height and scrolls its body rather than
growing past the viewport. */}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogContent
overlayClassName="bg-black/40 backdrop-blur-sm"
className="max-h-[90vh] overflow-y-auto sm:max-w-4xl"
>
<DialogHeader>
<DialogTitle>Add Auto Router</DialogTitle>
<DialogDescription>

View file

@ -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(
<AutoRouterClassifierTabs value={{ ...initial, classifier_type: "jev" }} onChange={onChange}>
Existing settings
</AutoRouterClassifierTabs>,
);
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();
});

View file

@ -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<AutoRouterClassifierTabsProps> = ({ value, onChange, children }) => {
const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
value,
onChange,
children,
judgeModel,
decisionModel,
}) => {
const id = useId();
const availability = useContext(AutoRouterAvailabilityContext);
const classifierType = effectiveClassifierType(value);
@ -167,10 +177,17 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
llm_v2: "Use the efficient model when its predicted quality is close enough to the capable model",
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<fieldset>
<legend className="mb-3 flex w-full items-center justify-between gap-3 text-sm font-medium">
What classifies your requests?
<legend className="mb-4 flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h4 className="m-0 text-xl font-semibold text-foreground">Choose your classifier</h4>
<SimpleTooltip content="Choose how requests are evaluated and routed to your tiers">
<button type="button" aria-label="About classifiers" className="text-muted-foreground">
<Info className="size-4" />
</button>
</SimpleTooltip>
</div>
<AutoRouterLimits />
</legend>
<RadioGroup value={family} onValueChange={changeFamily} className="grid gap-3 sm:grid-cols-3">
@ -179,8 +196,8 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ 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) => (
<Label
@ -206,33 +223,32 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
))}
</RadioGroup>
</fieldset>
{family === "jev" && (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">OSS provider</legend>
<RadioGroup
value={normalizeJevClassifierConfig(value.jev_classifier_config).provider}
onValueChange={changeProvider}
className="flex gap-6"
>
<Label>
<RadioGroupItem value="jev" />
Jev
</Label>
<Label>
<RadioGroupItem value="laya" />
Laya
</Label>
<Label>
<RadioGroupItem value="bespoke" />
Bespoke Nimble
</Label>
<Label>
<RadioGroupItem value="databricks" />
Databricks
</Label>
</RadioGroup>
</fieldset>
)}
{family === "jev" &&
(decisionModel ?? (
<div className="space-y-2">
<Label htmlFor={`${id}-decision-model`}>Decision model</Label>
<Select
items={[
{ value: "jev", label: "Jev" },
{ value: "laya", label: "Laya" },
{ value: "bespoke", label: "Bespoke Nimble" },
{ value: "databricks", label: "Databricks" },
]}
value={normalizeJevClassifierConfig(value.jev_classifier_config).provider}
onValueChange={changeProvider}
>
<SelectTrigger id={`${id}-decision-model`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="jev">Jev</SelectItem>
<SelectItem value="laya">Laya</SelectItem>
<SelectItem value="bespoke">Bespoke Nimble</SelectItem>
<SelectItem value="databricks">Databricks</SelectItem>
</SelectContent>
</Select>
</div>
))}
{family === "custom" && (
<p className="text-sm text-muted-foreground">This router uses a custom classifier plugin</p>
)}
@ -268,7 +284,8 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
</p>
</div>
)}
{(family === "llm" || family === "jev") && (
{family === "llm" && judgeModel}
{family === "llm" && (
<div className="space-y-2">
<Label htmlFor={`${id}-approach`}>Routing approach</Label>
<ClassifierMenu

View file

@ -147,6 +147,7 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
};
interface ClassificationMethodConfigProps {
jevSection?: "all" | "advanced";
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
modelOptions: { value: string; label: string }[];
@ -204,6 +205,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
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<ClassificationMethodConfigProps> = ({
</div>
)}
{showClassifier && classifierType === "jev" && <JevClassifierConfig value={value} onChange={onChange} />}
{showClassifier && classifierType === "jev" && (
<JevClassifierConfig value={value} onChange={onChange} section={jevSection} />
)}
{showClassifier && usesLlmClassifier(classifierType) && (
<div className="mt-4 space-y-3">
<ClassifierReasoningEffortSelect
@ -671,8 +675,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
/>
<span className="text-xs text-muted-foreground">
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.
</span>
</div>
<div>

View file

@ -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 (
<div className="mb-6 grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
<Select
items={[
{ value: "every_request", label: "Every request" },
{ value: "user_turn", label: "Every new user message" },
{ value: "session", label: "Once per session" },
]}
value={frequency}
onValueChange={(frequency) => {
if (frequency) onChange(withClassificationFrequency(value, frequency as ClassificationFrequency));
}}
>
<SelectTrigger id={`${id}-frequency`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="every_request">Every request</SelectItem>
<SelectItem value="user_turn">Every new user message</SelectItem>
<SelectItem value="session" disabled={Boolean(restriction)}>
Once per session
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{restriction?.reason ?? frequencyDescription}</p>
</div>
{usesJudge && (
<div
className={
{
all: "mb-6 grid gap-4 sm:grid-cols-2",
frequency: "mb-6 grid gap-4",
judge: "grid gap-4",
}[section]
}
>
{section !== "judge" && (
<div className="space-y-2">
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
<Select
items={[
{ value: "every_request", label: "Every request" },
{ value: "user_turn", label: "Every new user message" },
{ value: "session", label: "Once per session" },
]}
value={frequency}
onValueChange={(frequency) => {
if (frequency) onChange(withClassificationFrequency(value, frequency as ClassificationFrequency));
}}
>
<SelectTrigger id={`${id}-frequency`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="every_request">Every request</SelectItem>
<SelectItem value="user_turn">Every new user message</SelectItem>
<SelectItem value="session" disabled={Boolean(restriction)}>
Once per session
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{restriction?.reason ?? frequencyDescription}</p>
</div>
)}
{section !== "frequency" && usesJudge && (
<div className="space-y-2">
<Label htmlFor={`${id}-judge`}>Judge model</Label>
<SearchSelect

View file

@ -31,6 +31,7 @@ import type { AutoRouterCompressionState } from "./buildAutoRouterCompression";
import { activeTierName, type TierRow } from "./tier_rows";
interface ComplexityRouterAdvancedSectionsProps {
hideClassifier?: boolean;
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
forecast: boolean;
@ -59,6 +60,7 @@ interface ComplexityRouterAdvancedSectionsProps {
}
const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSectionsProps> = ({
hideClassifier,
value,
onChange,
forecast,
@ -268,7 +270,10 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
},
{ label: "Sessions and efficiency", keys: ["affinity", "adaptive", "cache-aware", "compression"] },
{ label: "Compatibility", keys: ["response"] },
].filter((group) => 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<string[]>(() =>
showValidationErrors ? groups.map((group) => group.label) : [],
);

View file

@ -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<ComplexityRouterConfigProps, "value" | "onChange"> & {
creationFlow: boolean | undefined;
modelOptions: { value: string; label: string }[];
classifierEffortOptionsByModel: Record<string, string[] | null | undefined>;
defaultModel: string | undefined;
showValidationErrors: boolean;
}) => {
const jevCreation = isJevCreation(value, creationFlow);
return (
<>
<ClassifierPrimarySettings
section={creationFlow ? "frequency" : "all"}
value={value}
onChange={onChange}
modelOptions={modelOptions}
showValidationErrors={showValidationErrors}
/>
{jevCreation && (
<div className="mb-6">
<RoutingOptions label="Advanced classifier settings" showValidationErrors={showValidationErrors}>
<div className="px-4 pb-4">
<ClassificationMethodConfig
advancedOnly
jevSection="advanced"
section="classifier"
value={value}
onChange={onChange}
modelOptions={modelOptions}
effortOptionsByModel={classifierEffortOptionsByModel}
defaultModel={defaultModel}
showValidationErrors={showValidationErrors}
/>
</div>
</RoutingOptions>
</div>
)}
</>
);
};
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
creationFlow,
tierSetup,
modelInfo,
value,
onChange,
@ -572,6 +627,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
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<ComplexityRouterConfigProps> = ({
return (
<div className="w-full max-w-none">
<ClassifierPrimarySettings
<CreationClassifierSettings
creationFlow={creationFlow}
value={value}
onChange={onChange}
modelOptions={modelOptions}
classifierEffortOptionsByModel={classifierEffortOptionsByModel}
defaultModel={defaultModel}
showValidationErrors={showValidationErrors}
/>
<div className="inline-flex items-center gap-2 mb-4">
<h4 className="m-0 text-xl font-semibold text-foreground">{forecast ? "Solver models" : "Models by tier"}</h4>
{!forecast && (
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
)}
{creationFlow && <Separator className="my-6" />}
<div className="mb-4">
<div className="inline-flex items-center gap-2">
<h4 className="m-0 text-xl font-semibold text-foreground">{forecast ? "Solver models" : tierHeading}</h4>
{!forecast && (
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
)}
</div>
</div>
<InactiveHeuristicV2Threshold value={value} onChange={onChange} />
@ -657,16 +719,9 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
) : (
<>
<TierConfigIntro value={value} />
{tierSetup}
<Card>
<CardContent>
{!customTierSet && (
<NonReasoningTierToggle
value={value}
onChange={onChange}
available={value.classifier_type === "llm" || value.classifier_type === "jev"}
/>
)}
{tierRows.map((row, index) => {
const tierInfo = builtInTierInfo(row.id);
const label = tierRowLabel(row, value.tier_labels);
@ -756,6 +811,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
);
})}
{!customTierSet && (
<NonReasoningTierToggle
value={value}
onChange={onChange}
available={value.classifier_type === "llm" || value.classifier_type === "jev"}
/>
)}
<TierSetToolbar
editing={editingTiers}
isCustomSet={Boolean(customTierSet)}
@ -779,7 +842,6 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</>
)}
<DefaultModelField value={value} onChange={onChange} modelOptions={modelOptions} />
<Separator className="my-6" />
<RoutingOptions
showValidationErrors={showValidationErrors}
@ -803,6 +865,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
)}
<div className="rounded-lg border border-border bg-muted">
<ComplexityRouterAdvancedSections
hideClassifier={isJevCreation(value, creationFlow)}
value={value}
onChange={onChange}
forecast={forecast}

View file

@ -0,0 +1,62 @@
import React from "react";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Label } from "@/components/ui/label";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { buildDecisionCatalog, isDecisionMode, isDecisionSelection } from "@/lib/decisionModels";
import { defaultJevClassifierConfig } from "./jev_classifier_config";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
export default function DecisionModelSelect({
value,
onChange,
modelInfo,
deployments,
}: {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => 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 (
<div className="space-y-2">
<Label htmlFor={id}>Decision model</Label>
<SearchSelect
inputId={id}
aria-label="Decision model"
options={decisionModels.map((model) => ({ 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,
},
});
}}
/>
</div>
);
}

View file

@ -111,19 +111,19 @@ 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" }), "Laya");
expect(screen.getByLabelText("Classifier Model")).toHaveTextContent("english");
fireEvent.click(screen.getByRole("radio", { name: "Jev" }));
await chooseSelectOption(userEvent, screen.getByRole("combobox", { name: "Decision model" }), "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" }), 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 +137,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" })).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);

View file

@ -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 (
<div className="mt-4 space-y-3">
<p className="text-sm text-muted-foreground">{providerDescriptions[config.provider ?? "jev"]}</p>
<div>
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
{models ? (
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
<SelectTrigger id={`${id}-model`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`${id}-model`}
value={config.model}
placeholder={
config.provider === "databricks" ? "Serving endpoint name, e.g. databricks-openjev-qwen35-4b" : undefined
<div className={section === "model" ? "space-y-3" : "mt-4 space-y-3"}>
{section !== "advanced" && !config.deployment_name && (
<>
<p className="text-sm text-muted-foreground">{providerDescriptions[config.provider ?? "jev"]}</p>
<div className="space-y-2">
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
{models ? (
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
<SelectTrigger id={`${id}-model`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`${id}-model`}
value={config.model}
placeholder={
config.provider === "databricks"
? "Serving endpoint name, e.g. databricks-openjev-qwen35-4b"
: undefined
}
onChange={(event) => update({ model: event.target.value })}
/>
)}
</div>
</>
)}
{section !== "model" && (
<>
<div className="space-y-2">
<Label htmlFor={`${id}-timeout`}>Classifier Timeout (ms)</Label>
<Input
id={`${id}-timeout`}
type="number"
min={1}
step={1}
value={config.timeout_ms}
onChange={(event) => update({ timeout_ms: Number(event.target.value) })}
/>
</div>
<ClassifierCircuitBreakerConfig
value={config}
onChange={(next) =>
update({
circuit_breaker_enabled: next.circuit_breaker_enabled,
circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds,
})
}
onChange={(event) => update({ model: event.target.value })}
/>
)}
</div>
<div>
<Label htmlFor={`${id}-timeout`}>Classifier Timeout (ms)</Label>
<Input
id={`${id}-timeout`}
type="number"
min={1}
step={1}
value={config.timeout_ms}
onChange={(event) => update({ timeout_ms: Number(event.target.value) })}
/>
</div>
<ClassifierCircuitBreakerConfig
value={config}
onChange={(next) =>
update({
circuit_breaker_enabled: next.circuit_breaker_enabled,
circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds,
})
}
/>
<div>
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
<AutoRouterAllowanceNote
feature="tier_or_classifier_prompt"
label="Custom instructions share the custom-tier allowance"
/>
<Textarea
id={`${id}-instructions`}
value={config.instructions ?? ""}
placeholder="Leave blank to use the built-in instructions"
onChange={(event) => update({ instructions: event.target.value || undefined })}
/>
{config.instructions && (
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
Restore built-in instructions
</Button>
)}
<p className="text-xs text-muted-foreground">
Built-in OSS classification is available without a license and uses the shipped tier criteria
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
<AutoRouterAllowanceNote
feature="tier_or_classifier_prompt"
label="Custom instructions share the custom-tier allowance"
/>
<Textarea
id={`${id}-instructions`}
value={config.instructions ?? ""}
placeholder="Leave blank to use the built-in instructions"
onChange={(event) => update({ instructions: event.target.value || undefined })}
/>
{config.instructions && (
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
Restore built-in instructions
</Button>
)}
<p className="text-xs text-muted-foreground">
Built-in decision model classification is available without a license and uses the shipped tier criteria
</p>
</div>
</>
)}
</div>
);
}

View file

@ -27,6 +27,7 @@ const NonReasoningTierToggle: React.FC<{
return (
<>
<Separator className="my-4" />
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.enable_non_reasoning_tier === true}
@ -41,7 +42,6 @@ const NonReasoningTierToggle: React.FC<{
reasoning about it. Escalation still moves up out of it when a request needs more.
{!available && " Requires the LLM or Jev classification method"}
</span>
<Separator className="my-4" />
</>
);
};

View file

@ -3,12 +3,18 @@ import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
interface RoutingOptionsProps {
label?: string;
showValidationErrors?: boolean;
summary?: string;
children: React.ReactNode;
}
const RoutingOptions = ({ showValidationErrors = false, summary, children }: RoutingOptionsProps) => {
const RoutingOptions = ({
label = "Advanced settings",
showValidationErrors = false,
summary,
children,
}: RoutingOptionsProps) => {
const [open, setOpen] = React.useState(false);
const [previousValidation, setPreviousValidation] = React.useState(showValidationErrors);
if (previousValidation !== showValidationErrors) {
@ -16,10 +22,10 @@ const RoutingOptions = ({ showValidationErrors = false, summary, children }: Rou
if (showValidationErrors) setOpen(true);
}
return (
<Collapsible open={open} onOpenChange={setOpen} className="rounded-lg border">
<Collapsible open={open} onOpenChange={setOpen} className="rounded-lg border bg-muted/40">
<CollapsibleTrigger className="group flex w-full flex-wrap items-center gap-2 px-4 py-3 text-left font-medium">
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
Advanced settings
{label}
{summary && <span className="text-xs font-normal text-muted-foreground">{summary}</span>}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4">{children}</CollapsibleContent>

View file

@ -22,7 +22,7 @@ import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { getMissingTiersError } from "./build_complexity_router_config";
import { getSubmitBlockedReason } from "./add_auto_router_tab";
import { buildModelAvailability } from "@/lib/autorouter_presets";
import { apiClient, modelCreateCall, testAutoRouterRouting } from "../networking";
import { apiClient, modelCostMap, modelCreateCall, testAutoRouterRouting } from "../networking";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import { AutoRouterPreset, getRequiredModelsInPreset } from "@/lib/autorouter_presets";
import { BUNDLED_PRESETS, LOADED_PRESETS_QUERY, useAutoRouterPresets } from "../../../tests/mocks/autoRouterPresets";
@ -55,7 +55,7 @@ const openTemplateDropdown = (): void => {
};
const expandDetailedConfiguration = (): void => {
expect(screen.getByText("Models by tier")).toBeVisible();
expect(screen.getByText("Define your tiers")).toBeVisible();
};
const visibleOptions = (): HTMLElement[] => screen.queryAllByRole("option");
@ -79,6 +79,14 @@ const expectTierModel = (tier: string, model: string): void => {
expect(chips.map((chip) => chip.getAttribute("aria-label"))).toEqual([model]);
};
const waitForAutomaticTiers = async () => {
await waitFor(() =>
expect(
within(tierChips("Simple")).getAllByLabelText(/.+/, { selector: '[data-slot="combobox-chip"]' }).length,
).toBeGreaterThan(0),
);
};
const selectTemplate = async (label: string): Promise<void> => {
await userEvent.click(optionByLabel(label)!);
};
@ -115,6 +123,7 @@ vi.mock("../networking", () => ({
error: null,
}),
},
modelCostMap: vi.fn().mockResolvedValue({}),
modelCreateCall: vi.fn().mockResolvedValue({}),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
testAutoRouterRouting: vi.fn(),
@ -180,6 +189,104 @@ describe("AddAutoRouterTab", () => {
testQueryClient.clear();
mockFetchAvailableModels.mockResolvedValue([]);
mockFetchAllModelDeployments.mockResolvedValue([]);
vi.mocked(modelCostMap).mockResolvedValue({});
});
it("shows Jev essentials before tiers and saves tuning from its own disclosure", async () => {
mockFetchAvailableModels.mockResolvedValue([
...ALL_FAMILY_MODELS,
{ model_group: "my-decision", mode: "evaluation" },
]);
renderWithProviders(<Harness />);
await waitForAutomaticTiers();
await userEvent.click(screen.getByRole("radio", { name: "Decisions Model" }));
expect(screen.queryByLabelText("Classifier Model")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
expect(screen.queryByRole("option", { name: ALL_FAMILY_MODELS[0].model_group })).not.toBeInTheDocument();
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
expect(screen.queryByRole("button", { name: "Routing approach" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Choose models for me" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Classifier Timeout (ms)")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
await userEvent.click(screen.getByRole("button", { name: "Advanced classifier settings" }));
fireEvent.change(screen.getByLabelText("Classifier Timeout (ms)"), { target: { value: "4200" } });
fireEvent.change(screen.getByLabelText("Context Window Size"), { target: { value: "5" } });
await selectAutoRouterOption("How often to classify", "Every new user message");
await userEvent.click(screen.getByRole("button", { name: "Advanced classifier settings" }));
await userEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
expect(screen.queryByRole("button", { name: "Classifier tuning" })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "jev-router" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
const expected = {
classifier_type: "oss_classifier",
opensource_classifier_config: { deployment_name: "my-decision", timeout_ms: 4200 },
classifier_context_window_size: 5,
classification_mode: "user_turn",
};
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject(expected);
});
it.each([
{ mode: "evaluation" },
{ mode: "chat", supported_endpoints: ["/v1/decisions"] },
{ supported_endpoints: ["/v1/systemone"] },
])("selects and saves an onboarded alias using decision catalog metadata %j", async (metadata) => {
const alias = "onboarded-decider";
const underlyingModel = "mixed-provider/decision-model";
vi.mocked(modelCostMap).mockResolvedValue({
[underlyingModel]: { litellm_provider: "mixed-provider", ...metadata },
"mixed-provider/chat-model": { litellm_provider: "mixed-provider", mode: "chat" },
"catalog-only/decision-model": { litellm_provider: "catalog-only", mode: "evaluation" },
});
mockFetchAvailableModels.mockResolvedValue([
...ALL_FAMILY_MODELS,
{ model_group: alias },
{ model_group: "ordinary-chat", mode: "chat" },
]);
mockFetchAllModelDeployments.mockResolvedValue([
{ model_name: alias, litellm_params: { model: underlyingModel } },
{ model_name: "ordinary-chat", litellm_params: { model: "mixed-provider/chat-model" } },
{ model_name: "unavailable-decider", litellm_params: { model: underlyingModel } },
]);
renderWithProviders(<Harness />);
await waitForAutomaticTiers();
await userEvent.click(screen.getByRole("radio", { name: "Decisions Model" }));
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
const option = await screen.findByRole("option", { name: alias });
expect(screen.getAllByRole("option")).toHaveLength(1);
await userEvent.click(option);
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "decision-router" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
classifier_type: "oss_classifier",
opensource_classifier_config: { deployment_name: alias },
});
});
it("uses the judge picker empty state when no decision deployments exist", async () => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await waitForAutomaticTiers();
await userEvent.click(screen.getByRole("radio", { name: "Decisions Model" }));
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
expect(await screen.findByText("No results")).toBeVisible();
await userEvent.keyboard("{Escape}");
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
});
it("does not refill a tier cleared by the user when models refresh", async () => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await waitForAutomaticTiers();
await userEvent.click(within(tierChips("Simple")).getByRole("button", { name: "" }));
await act(async () => {
await testQueryClient.refetchQueries({ queryKey: ["availableModels"] });
});
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
});
it.each([1, 0])("defaults to Rule-based with %s v2 slots remaining", async (remaining) => {
@ -272,10 +379,15 @@ describe("AddAutoRouterTab", () => {
error: body?.complexity_router_config?.tier_definitions ? blocked : null,
};
});
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
mockFetchAvailableModels.mockResolvedValue([
...ALL_FAMILY_MODELS,
{ model_group: "my-decision", mode: "evaluation" },
]);
renderWithProviders(<Harness />);
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
await user.click(screen.getByRole("radio", { name: "OSS Classifier" }));
await waitForAutomaticTiers();
await user.click(screen.getByRole("radio", { name: "Decisions Model" }));
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
await waitFor(() =>
expect(apiClient.post).toHaveBeenLastCalledWith(
"/auto_router/availability",
@ -302,7 +414,7 @@ describe("AddAutoRouterTab", () => {
expect(within(screen.getByRole("alert")).getByRole("link", { name: "Talk to our team" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Restore defaults" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByRole("radio", { name: "OSS Classifier" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Decisions Model" })).toBeChecked();
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
@ -315,10 +427,15 @@ describe("AddAutoRouterTab", () => {
it("blocks button and Enter submissions until the edited draft is checked", async () => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
mockFetchAvailableModels.mockResolvedValue([
...ALL_FAMILY_MODELS,
{ model_group: "my-decision", mode: "evaluation" },
]);
renderWithProviders(<Harness />);
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
await user.click(screen.getByRole("radio", { name: "OSS Classifier" }));
await waitForAutomaticTiers();
await user.click(screen.getByRole("radio", { name: "Decisions Model" }));
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "checked-router" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
let complete: ((result: unknown) => void) | undefined;
@ -358,15 +475,22 @@ describe("AddAutoRouterTab", () => {
expect(screen.getByRole("button", { name: "Routing approach" })).toHaveTextContent("Complexity");
});
it.each(["LLM", "OSS Classifier"])(
"keeps %s and the frequency when choosing models automatically",
it.each(["LLM", "Decisions Model"])(
"keeps %s and the frequency selected before automatic models finish loading",
async (family) => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
let complete: ((models: ModelGroup[]) => void) | undefined;
mockFetchAvailableModels.mockReturnValue(
new Promise<ModelGroup[]>((resolve) => {
complete = resolve;
}),
);
renderWithProviders(<Harness />);
const automatic = await screen.findByRole("button", { name: "Choose models for me" });
await userEvent.click(screen.getByRole("radio", { name: family }));
await selectAutoRouterOption("How often to classify", "Every new user message");
await userEvent.click(automatic);
await act(async () => {
complete?.(ALL_FAMILY_MODELS);
});
await waitForAutomaticTiers();
expect(screen.getByRole("radio", { name: family })).toBeChecked();
expect(screen.getByRole("combobox", { name: "How often to classify" })).toHaveTextContent(
"Every new user message",
@ -452,14 +576,14 @@ describe("AddAutoRouterTab", () => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await screen.findByTestId("configure-automatically-button");
await waitForAutomaticTiers();
await selectAutoRouterApproach("Capability");
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
await selectAutoRouterApproach("Complexity");
expect(screen.getByTestId("configure-automatically-button")).toBeInTheDocument();
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
expect(screen.getByTestId("template-selector")).toBeInTheDocument();
expandDetailedConfiguration();
expect(screen.getByText("Models by tier")).toBeInTheDocument();
expect(screen.getByText("Define your tiers")).toBeInTheDocument();
for (const label of ["Adaptive Routing", "Context Window Escalation", "Escalation Keywords"]) {
openAutoRouterAdvanced(label);
expect(screen.getAllByText(label)).not.toHaveLength(0);
@ -507,9 +631,9 @@ describe("AddAutoRouterTab", () => {
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Models by tier")).toBeVisible();
expect(screen.getByText("Define your tiers")).toBeVisible();
expect(screen.getByText("Models by tier")).toBeInTheDocument();
expect(screen.getByText("Define your tiers")).toBeInTheDocument();
});
it("hides automatic setup when no available model is recommended", async () => {
@ -535,8 +659,7 @@ describe("AddAutoRouterTab", () => {
mockFetchAllModelDeployments.mockResolvedValue([]);
renderWithProviders(<Harness />);
const button = await screen.findByTestId("configure-automatically-button");
await userEvent.click(button);
await waitForAutomaticTiers();
expectTierModel("Simple", "gpt-5.6-luna");
expectTierModel("Medium", "claude-sonnet-5");
@ -555,8 +678,7 @@ describe("AddAutoRouterTab", () => {
mockFetchAllModelDeployments.mockResolvedValue([]);
renderWithProviders(<Harness />);
const button = await screen.findByTestId("configure-automatically-button");
await userEvent.click(button);
await waitForAutomaticTiers();
expectTierModel("Simple", "gpt-5.6-luna");
expectTierModel("Medium", "claude-sonnet-5");
@ -571,9 +693,9 @@ describe("AddAutoRouterTab", () => {
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
await userEvent.click(await screen.findByTestId("configure-automatically-button"));
await waitForAutomaticTiers();
expect(screen.getByText("Models by tier")).toBeInTheDocument();
expect(screen.getByText("Define your tiers")).toBeInTheDocument();
expectTierModel("Simple", simpleModel);
});
@ -938,9 +1060,7 @@ describe("AddAutoRouterTab", () => {
);
vi.mocked(handleAddAutoRouterSubmit).mockImplementationOnce(actualSubmit.handleAddAutoRouterSubmit);
renderWithProviders(<Harness />);
const setup = await screen.findByRole("button", { name: "Choose models for me" });
await waitFor(() => expect(setup).toBeEnabled());
await userEvent.click(setup);
await waitForAutomaticTiers();
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "chained-router" } });
await userEvent.click(screen.getByRole("radio", { name: "LLM" }));
await selectAutoRouterOption("Judge model", ALL_FAMILY_MODELS[0].model_group);
@ -968,16 +1088,16 @@ describe("AddAutoRouterTab", () => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
const automaticSetup = await screen.findByRole("button", { name: "Choose models for me" });
await waitFor(() => expect(automaticSetup).toBeEnabled());
await user.click(automaticSetup);
await waitForAutomaticTiers();
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "reset-threshold-router" } });
openAutoRouterAdvanced("Classification Method");
await selectAutoRouterOption("Heuristic", "Heuristic v2");
fireEvent.change(screen.getByRole("textbox", { name: "Success threshold" }), { target: { value: "1.1" } });
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
await user.click(automaticSetup);
await act(async () => {
await testQueryClient.refetchQueries({ queryKey: ["availableModels"] });
});
expect(screen.getByRole("textbox", { name: "Success threshold" })).toHaveValue("1.1");
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
fireEvent.change(screen.getByRole("textbox", { name: "Success threshold" }), { target: { value: "" } });
@ -1014,9 +1134,7 @@ describe("AddAutoRouterTab", () => {
const user = userEvent.setup();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
const setup = await screen.findByRole("button", { name: "Choose models for me" });
await waitFor(() => expect(setup).toBeEnabled());
await user.click(setup);
await waitForAutomaticTiers();
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "cache-aware-router" } });
openAutoRouterAdvanced("Cache-aware routing");
const toggle = screen.getByRole("switch", { name: "Cache-aware routing" });
@ -1346,7 +1464,7 @@ describe("AddAutoRouterTab", () => {
// Custom is the escape hatch, not the headline choice, so it's listed after every bundled preset
// rather than first.
it("lists Custom Configuration after the bundled presets", async () => {
it("lists Start from scratch after the bundled presets", async () => {
renderWithProviders(<Harness />);
openTemplateDropdown();
@ -1358,7 +1476,7 @@ describe("AddAutoRouterTab", () => {
"Gemini Family",
"Lite",
"OpenAI Family",
"Custom Configuration",
"Start from scratch",
]);
});
@ -1516,11 +1634,11 @@ describe("AddAutoRouterTab", () => {
expectTierModel("reasoning", ANTHROPIC_TIERS.REASONING[0]);
});
it("expands detailed configuration when Custom Configuration is chosen", async () => {
it("expands detailed configuration when Start from scratch is chosen", async () => {
renderWithProviders(<Harness />);
openTemplateDropdown();
await selectTemplate("Custom Configuration");
await selectTemplate("Start from scratch");
openAutoRouterAdvanced("Keyword/Semantic Matching");
@ -1534,7 +1652,7 @@ describe("AddAutoRouterTab", () => {
await selectTemplate("Anthropic Family");
expect(screen.queryByText("Keyword/Semantic Matching")).not.toBeInTheDocument();
expect(screen.getByText("Models by tier")).toBeVisible();
expect(screen.getByText("Define your tiers")).toBeVisible();
openAutoRouterAdvanced("Keyword/Semantic Matching");
@ -1803,7 +1921,7 @@ describe("AddAutoRouterTab", () => {
"Gemini Family",
"Lite",
"OpenAI Family",
"Custom Configuration",
"Start from scratch",
]);
});
@ -1941,7 +2059,10 @@ describe("preset catalog fetch states", () => {
vi.clearAllMocks();
testQueryClient.clear();
vi.mocked(handleAddAutoRouterSubmit).mockReset();
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
mockFetchAvailableModels.mockResolvedValue([
...ALL_FAMILY_MODELS,
{ model_group: "my-decision", mode: "evaluation" },
]);
vi.mocked(useAutoRouterPresets).mockReturnValue({
...LOADED_PRESETS_QUERY,
data: [
@ -1961,6 +2082,8 @@ describe("preset catalog fetch states", () => {
renderWithProviders(<Harness />);
await waitForPresetEnabled("Bounded JEV");
await selectTemplate("Bounded JEV");
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "bounded-router" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
@ -1996,7 +2119,7 @@ describe("preset catalog fetch states", () => {
expect(screen.getByText("Loading templates...")).toBeInTheDocument();
});
it("degrades to Custom Configuration with a retry hint that refetches the catalog", async () => {
it("degrades to Start from scratch with a retry hint that refetches the catalog", async () => {
const refetch = vi.fn();
vi.mocked(useAutoRouterPresets).mockReturnValue({
...LOADED_PRESETS_QUERY,
@ -2011,7 +2134,7 @@ describe("preset catalog fetch states", () => {
openTemplateDropdown();
const options = screen.queryAllByRole("option");
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent("Custom Configuration");
expect(options[0]).toHaveTextContent("Start from scratch");
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(refetch).toHaveBeenCalled();

View file

@ -1,4 +1,7 @@
import { AutoRouterAvailabilityContext, useAutoRouterAvailability } from "./AutoRouterAvailability";
import { decisionDeploymentName } from "./jev_classifier_config";
import DecisionModelSelect from "./DecisionModelSelect";
import ClassifierPrimarySettings from "./ClassifierPrimarySettings";
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
import { getForecastConfigError, isForecastClassifier } from "../add_model/forecast_classifier_config";
import React, { useEffect, useState } from "react";
@ -10,7 +13,7 @@ import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
@ -55,7 +58,6 @@ import {
} from "./build_complexity_router_config";
import { builderParamsFromValue } from "./complexity_router_builder_params";
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
import { tierRowLabel } from "./complexity_router_tiers";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import { AutoRouterConnectionTestDialog } from "./auto_router_connection_test";
import {
@ -77,7 +79,7 @@ import {
} from "@/lib/autorouter_presets";
import { useAutoRouterPresets } from "@/app/(dashboard)/hooks/autoRouter/useAutoRouterPresets";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { buildAutomaticRouterConfig, buildPreferredTierModels } from "./auto_setup";
import { buildAutomaticRouterConfig, buildPreferredTierModels, prefillEmptyTiers } from "./auto_setup";
interface AddAutoRouterTabProps {
handleOk: () => void;
@ -120,15 +122,6 @@ const isPresetHintAlarming = (availability: PresetAvailability): boolean => avai
const NO_PRESETS: AutoRouterPreset[] = [];
// A one-line summary of what's configured, shown when the detailed section is collapsed so a
// caller can see the shape of the config without opening it.
const tierConfigSummary = (config: ComplexityRouterConfigValue): string => {
const parts = activeTierRows(config)
.filter((row) => row.models.length > 0)
.map((row) => `${tierRowLabel(row, config.tier_labels)}: ${row.models.join(", ")}`);
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
};
// Why the submit is unavailable, or null when it is available. The button reads this to disable
// itself and to say what is missing, so the two can never give different answers. Checks the
// config actually being built, not which preset (if any) it came from: a preset only ever
@ -149,6 +142,9 @@ export const getSubmitBlockedReason = (
: getMissingTiersError(activeTierRows(config))) ??
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
(effectiveClassifierType(config) === "jev" && !config.jev_classifier_config?.deployment_name
? "Please select a decision model"
: null) ??
getClassifierModelError(config) ??
getHeuristicV2SuccessThresholdError(config.heuristic_v2_success_threshold) ??
getReminderMarkersError(config.reminder_markers) ??
@ -337,7 +333,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const templateItems = React.useMemo(
() => [
...sortedPresetOptions.map(({ preset }) => ({ value: preset.key, label: preset.label })),
{ value: "custom", label: "Custom Configuration" },
{ value: "custom", label: "Start from scratch" },
],
[sortedPresetOptions],
);
@ -353,20 +349,16 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
setEscalationKeywords(prefill.escalationKeywords);
};
const handleAutomaticSetup = () => {
if (automaticRouterConfig === null) return;
setSelectedPreset(undefined);
setComplexityRouterConfig({
...complexityRouterConfig,
tiers: { ...complexityRouterConfig.tiers, ...automaticRouterConfig.tiers },
tier_model_params: { ...complexityRouterConfig.tier_model_params, ...automaticRouterConfig.tier_model_params },
});
toast.success("Models selected", { description: tierConfigSummary(automaticRouterConfig) });
};
const [prefillAttempted, setPrefillAttempted] = useState(false);
const automaticPrefill = automaticSetupLoading || modelsUnverifiable ? null : automaticRouterConfig;
if (automaticPrefill && !prefillAttempted) {
setPrefillAttempted(true);
setComplexityRouterConfig((current) => prefillEmptyTiers(current, automaticPrefill));
}
const handlePresetChange = (presetKey: string | undefined) => {
if (!presetKey || presetKey === "custom") {
setPrefillAttempted(true);
setSelectedPreset(presetKey);
applyPrefill(buildEmptyPrefill());
@ -380,6 +372,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
const presetState = presetAvailability(preset);
if (presetState.kind !== "available") return;
setPrefillAttempted(true);
setSelectedPreset(presetKey);
applyPrefill(buildPresetPrefill(preset.complexity_router_config, availability));
};
@ -388,6 +381,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
tiers: Object.fromEntries(activeTierRows(complexityRouterConfig).map((row) => [activeTierName(row), row.models])),
classifierType: effectiveClassifierType(complexityRouterConfig),
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
decisionDeploymentName: decisionDeploymentName(complexityRouterConfig.jev_classifier_config),
semanticMatchingEnabled,
embeddingModel,
defaultModel: complexityRouterConfig.default_model,
@ -546,8 +540,71 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
setIsTestModalVisible(true);
};
const tierSetup = (
<div className="mb-4 w-full space-y-2">
<div className="space-y-2">
<Select
items={templateItems}
value={selectedPreset ?? null}
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
>
<SelectTrigger aria-label="Use a template" data-testid="template-selector" className="w-full">
<SelectValue placeholder="Use a template" />
</SelectTrigger>
<SelectContent className="[&_[data-slot=select-item]>div]:min-w-0 [&_[data-slot=select-item]>div]:shrink [&_[data-slot=select-item]>div]:whitespace-normal [&_[data-slot=select-item]>div]:break-words">
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
const disabledHint = presetDisabledHint(presetState);
const hintClass = isPresetHintAlarming(presetState) ? "text-destructive" : "text-muted-foreground";
const matchedHint =
presetState.kind === "available" && presetState.viaDeployments ? "Matches your deployments" : null;
return (
<SelectItem
key={preset.key}
value={preset.key}
label={preset.label}
disabled={disabledHint !== null}
title={disabledHint ?? preset.description}
>
<div>
<div className="font-medium">{preset.label}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
<div className="text-xs text-muted-foreground">Replaces classifier and model choices</div>
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
</div>
</SelectItem>
);
})}
<SelectSeparator className="mx-0" />
<SelectItem value="custom" label="Start from scratch">
<div>
<div className="font-medium">Start from scratch</div>
<div className="text-xs text-muted-foreground">
Clears tier models, switches to Heuristics, and resets routing settings
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Replaces classifier and model choices</p>
{presetsPending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
{presetsUnavailable && (
<div className="text-xs mt-1 text-destructive">
Could not load templates, so only Start from scratch is shown.{" "}
<button type="button" className="underline" onClick={() => void refetchPresets()}>
Retry
</button>
</div>
)}
</div>
</div>
);
const configurationForm = (
<ComplexityRouterConfig
creationFlow
tierSetup={tierSetup}
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
modelInfo={modelInfo}
@ -571,7 +628,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
showValidationErrors={showValidationErrors}
/>
);
const forecast = isForecastClassifier(complexityRouterConfig.classifier_type);
return (
<AutoRouterAvailabilityContext.Provider value={routerAvailability}>
@ -579,7 +635,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
<Card>
<CardContent>
<form onSubmit={form.handleSubmit(() => handleAutoRouterSubmit())} noValidate>
<div className="mb-6">
<div className="mb-4">
<FormField
control={form.control}
name="auto_router_name"
@ -590,6 +646,31 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
)}
</FormField>
</div>
<div className="mb-6 border-b pb-6">
{isAdmin && (
<div>
<FormField
control={form.control}
name="model_access_group"
label={labelWithHint(
"Model Access Group",
"Use model access groups to control who can access this auto router",
)}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<AccessGroupTagsCombobox
id={id}
value={value}
onChange={onChange}
options={modelAccessGroups}
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
</div>
)}
</div>
{requiresTeamScope && (
<FormField
control={form.control}
@ -610,129 +691,39 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
</FormField>
)}
<AutoRouterClassifierTabs value={complexityRouterConfig} onChange={handleClassifierChange}>
<AutoRouterClassifierTabs
value={complexityRouterConfig}
onChange={handleClassifierChange}
decisionModel={
<DecisionModelSelect
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
modelInfo={modelInfo}
deployments={deployments}
/>
}
judgeModel={
<ClassifierPrimarySettings
section="judge"
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
modelOptions={modelInfo
.filter((model) => model.mode !== "embedding")
.map((model) => ({ value: model.model_group, label: model.model_group }))}
showValidationErrors={showValidationErrors}
/>
}
>
<FieldGroup>
<div className="space-y-4 empty:hidden">
{!forecast && (
<>
{!automaticSetupLoading && automaticRouterConfig && !complexityRouterConfig.custom_tier_set && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted px-4 py-3">
<div>
<p className="text-sm font-medium text-foreground">Choose models quickly</p>
<p className="text-sm text-muted-foreground">
Fill the tiers while keeping your classifier and settings.
</p>
</div>
<Button
type="button"
data-testid="configure-automatically-button"
onClick={handleAutomaticSetup}
>
Choose models for me
</Button>
</div>
)}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
<Select
items={templateItems}
value={selectedPreset ?? null}
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
>
<SelectTrigger data-testid="template-selector" className="w-full">
<SelectValue placeholder="Choose a template or select Custom to define your own" />
</SelectTrigger>
<SelectContent>
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
const disabledHint = presetDisabledHint(presetState);
const hintClass = isPresetHintAlarming(presetState)
? "text-destructive"
: "text-muted-foreground";
const matchedHint =
presetState.kind === "available" && presetState.viaDeployments
? "Matches your deployments"
: null;
return (
<SelectItem
key={preset.key}
value={preset.key}
label={preset.label}
disabled={disabledHint !== null}
title={disabledHint ?? preset.description}
>
<div>
<div className="font-medium">{preset.label}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
<div className="text-xs text-muted-foreground">
Replaces classifier and model choices
</div>
{disabledHint && (
<div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>
)}
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
</div>
</SelectItem>
);
})}
<SelectItem value="custom" label="Custom Configuration">
<div>
<div className="font-medium">Custom Configuration</div>
<div className="text-xs text-muted-foreground">
Define your auto router from scratch
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
{presetsPending && (
<div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>
)}
{presetsUnavailable && (
<div className="text-xs mt-1 text-destructive">
Could not load templates, so only Custom Configuration is shown.{" "}
<button type="button" className="underline" onClick={() => void refetchPresets()}>
Retry
</button>
</div>
)}
</div>
</>
)}
{modelsUnverifiable && (
<div className="text-xs mt-1 text-destructive">
Could not load available models.{" "}
<button type="button" className="underline" onClick={() => refetchModels()}>
Retry
</button>
</div>
)}
</div>
{configurationForm}
{isAdmin && (
<FormField
control={form.control}
name="model_access_group"
label={labelWithHint(
"Model Access Group",
"Use model access groups to control who can access this auto router",
)}
>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<AccessGroupTagsCombobox
id={id}
value={value}
onChange={onChange}
options={modelAccessGroups}
ariaInvalid={ariaInvalid}
ariaDescribedBy={ariaDescribedBy}
/>
)}
</FormField>
{modelsUnverifiable && (
<p className="text-sm text-destructive">
Could not load available models.{" "}
<button type="button" className="underline" onClick={() => void refetchModels()}>
Retry
</button>
</p>
)}
{configurationForm}
<div className="flex flex-wrap items-center justify-between gap-3">
<Tooltip>

View file

@ -1,7 +1,13 @@
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { describe, expect, it } from "vitest";
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import { buildModelAvailability, deploymentRefsFromModelInfo } from "@/lib/autorouter_presets";
import { buildAutomaticRouterConfig, buildPreferredTierModels, type PreferredTierModels } from "./auto_setup";
import {
buildAutomaticRouterConfig,
buildPreferredTierModels,
prefillEmptyTiers,
type PreferredTierModels,
} from "./auto_setup";
const models = (...names: string[]) => names.map((model_group) => ({ model_group, mode: "chat" }));
const reasoningModel = (model_group: string, supported_reasoning_efforts: string[]) => ({
@ -230,3 +236,39 @@ describe("buildAutomaticRouterConfig", () => {
).toEqual(["gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini"]);
});
});
describe("prefillEmptyTiers", () => {
const empty: ComplexityRouterConfigValue = {
classifier_type: "jev",
jev_classifier_config: { model: "jev-custom", timeout_ms: 4200 },
classification_mode: "user_turn",
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
};
const automatic: ComplexityRouterConfigValue = {
classifier_type: "heuristic_v2",
tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["strong"] },
tier_model_params: { REASONING: { strong: { reasoning_effort: "high" } } },
};
it("fills tiers while preserving a classifier configured before models load", () => {
expect(prefillEmptyTiers(empty, automatic)).toEqual({
...empty,
tiers: automatic.tiers,
tier_model_params: automatic.tier_model_params,
});
});
it("leaves the draft alone when no recommended models are available", () => {
expect(prefillEmptyTiers(empty, null)).toBe(empty);
});
it("preserves a partial manual assignment", () => {
const partial = { ...empty, tiers: { ...empty.tiers, SIMPLE: ["manual"] } };
expect(prefillEmptyTiers(partial, automatic)).toBe(partial);
});
it("preserves a custom tier set created before models load", () => {
const custom = { ...empty, custom_tier_set: { tiers: [], fallback_tier_id: "" } };
expect(prefillEmptyTiers(custom, automatic)).toBe(custom);
});
it.each(["capability", "llm_v2"] as const)("does not prefill %s solver pools", (classifier_type) => {
const solver = { ...empty, classifier_type };
expect(prefillEmptyTiers(solver, automatic)).toBe(solver);
});
});

View file

@ -1,3 +1,5 @@
import { activeTierRows } from "./tier_rows";
import { isForecastClassifier } from "./forecast_classifier_config";
import { isAutoRouterDeployment, type AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import { resolveAvailableModel, type AutoRouterPreset, type ModelAvailability } from "@/lib/autorouter_presets";
@ -93,3 +95,16 @@ export const buildAutomaticRouterConfig = (
}),
};
};
export const prefillEmptyTiers = (
current: ComplexityRouterConfigValue,
automatic: ComplexityRouterConfigValue | null,
): ComplexityRouterConfigValue => {
if (!automatic || current.custom_tier_set || isForecastClassifier(current.classifier_type)) return current;
if (activeTierRows(current).some((row) => row.models.length > 0)) return current;
return {
...current,
tiers: { ...current.tiers, ...automatic.tiers },
tier_model_params: { ...current.tier_model_params, ...automatic.tier_model_params },
};
};

View file

@ -42,3 +42,24 @@ describe("jevClassifierConfigSchema", () => {
},
);
});
it("preserves legacy classifier tuning when the backend returns a null deployment reference", () => {
const legacyConfig = {
deployment_name: null,
provider: "laya",
model: "english",
timeout_ms: 4100,
};
const parsed = jevClassifierConfigSchema.parse(legacyConfig);
expect(parsed).toMatchObject({ provider: "laya", model: "english", timeout_ms: 4100 });
expect(parsed.deployment_name).toBeUndefined();
});
it("preserves a deployment alias independently of legacy provider model validation", () => {
const parsed = jevClassifierConfigSchema.parse({
deployment_name: "decider",
provider: "databricks",
timeout_ms: 4100,
});
expect(parsed.deployment_name).toBe("decider");
});

View file

@ -27,6 +27,7 @@ const isSupportedClassifierModel = (provider: OssClassifierProvider | undefined,
};
const jevClassifierConfigFields = {
deployment_name: z.preprocess((value) => value ?? undefined, z.string().trim().min(1).optional()),
provider: z.preprocess(
(value) => (value === "typesafe" ? "jev" : value),
z.enum(["jev", "laya", "bespoke", "databricks"]).optional(),
@ -45,10 +46,13 @@ const jevClassifierConfigFields = {
export const jevClassifierConfigSchema = z
.object(jevClassifierConfigFields)
.transform((config) => ({ ...config, model: config.model ?? defaultClassifierModel(config.provider) }))
.refine((config) => isSupportedClassifierModel(config.provider, config.model), {
error: "Select a supported classifier model, or enter the bare Databricks serving endpoint name",
path: ["model"],
});
.refine(
(config) => config.deployment_name !== undefined || isSupportedClassifierModel(config.provider, config.model),
{
error: "Select a supported classifier model, or enter the bare Databricks serving endpoint name",
path: ["model"],
},
);
export type JevClassifierConfig = z.infer<typeof jevClassifierConfigSchema>;
@ -73,6 +77,7 @@ export const hydrateOssClassifier = (config: {
export const normalizeJevClassifierConfig = (
config: JevClassifierConfig = defaultJevClassifierConfig(),
): JevClassifierConfig => ({
...(config.deployment_name !== undefined && { deployment_name: config.deployment_name }),
provider: config.provider ?? "jev",
model: config.model.trim(),
timeout_ms: config.timeout_ms,
@ -82,3 +87,6 @@ export const normalizeJevClassifierConfig = (
circuit_breaker_cooldown_seconds: config.circuit_breaker_cooldown_seconds,
}),
});
export const decisionDeploymentName = (config: JevClassifierConfig | undefined): string | undefined =>
config?.deployment_name;

View file

@ -1,3 +1,7 @@
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
useModelCostMap: () => ({ data: { "typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" } } }),
}));
import { openAutoRouterAdvanced, selectAutoRouterOption } from "../../../tests/autoRouterSetup";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@ -7,6 +11,7 @@ import { act, fireEvent, renderWithProviders, screen, waitFor, within, testQuery
import { toast } from "@/lib/toast";
import EditAutoRouterModal from "./edit_auto_router_modal";
import { apiClient } from "../networking";
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
vi.mock(
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
@ -30,6 +35,12 @@ vi.mock("../networking", () => ({
apiClient: { post: vi.fn().mockResolvedValue({ allowances: [], error: null }) },
modelPatchUpdateCall,
modelAvailableCall,
modelInfoCall: vi.fn().mockResolvedValue({
data: [
{ model_name: "decision-one", litellm_params: { model: "typesafe/jev-latest" } },
{ model_name: "decision-two", litellm_params: { model: "typesafe/jev-latest" } },
],
}),
getAutoRouterClassifierDefaultPromptCall,
getAutoRouterAssembledPromptCall,
validateAutoRouterConfig,
@ -1571,3 +1582,47 @@ describe("EditAutoRouterModal availability checks", () => {
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalledOnce());
});
});
describe("EditAutoRouterModal deployed decision classifier", () => {
it.each(["evaluation", undefined])(
"shows and changes the saved deployment without ignored legacy model controls (mode=%s)",
async (mode) => {
vi.mocked(fetchAvailableModels).mockResolvedValueOnce([
{ model_group: "decision-one", mode },
{ model_group: "decision-two", mode },
{ model_group: "gpt-4o-mini", mode: "chat" },
]);
modelPatchUpdateCall.mockClear();
renderModal({
modelData: {
...MODEL_DATA,
litellm_params: {
...MODEL_DATA.litellm_params,
complexity_router_config: {
...STORED_CONFIG,
classifier_type: "oss_classifier",
opensource_classifier_config: { provider: "jev", model: "jev-latest", deployment_name: "decision-one" },
},
},
},
});
const picker = await screen.findByRole("combobox", { name: "Decision model" });
expect(picker).toHaveValue("decision-one");
await userEvent.click(picker);
await userEvent.click(await screen.findByRole("option", { name: "decision-two" }));
expect(picker).toHaveValue("decision-two");
openAutoRouterAdvanced("Classification Method");
expect(screen.queryByLabelText("Classifier Model")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Classifier Timeout (ms)"), { target: { value: "4500" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
expect(savedConfig().opensource_classifier_config).toEqual(
expect.objectContaining({
deployment_name: "decision-two",
timeout_ms: 4500,
}),
);
},
);
});

View file

@ -1,3 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
import DecisionModelSelect from "../add_model/DecisionModelSelect";
import { AutoRouterAvailabilityContext, useAutoRouterAvailability } from "../add_model/AutoRouterAvailability";
import AutoRouterClassifierTabs from "../add_model/AutoRouterClassifierTabs";
import { usesClassifierContext } from "../add_model/classifier_types";
@ -236,6 +240,42 @@ export const buildUpdatedComplexityRouterConfig = (
};
};
const EditClassifierTabs = ({
value,
onChange,
children,
modelInfo,
accessToken,
userRole,
isVisible,
}: React.ComponentProps<typeof AutoRouterClassifierTabs> & {
modelInfo: ModelGroup[];
accessToken: string;
userRole: string;
isVisible: boolean;
}) => {
const { userId } = useAuthorized();
const hasDeployment = Boolean(value.jev_classifier_config?.deployment_name);
const { data: deployments } = useQuery({
queryKey: autoRouterListKey(userId ?? "", userRole),
queryFn: () => fetchAllModelDeployments(accessToken, userId ?? "", userRole),
enabled: Boolean(isVisible && accessToken && hasDeployment),
});
return (
<AutoRouterClassifierTabs
value={value}
onChange={onChange}
decisionModel={
hasDeployment ? (
<DecisionModelSelect value={value} onChange={onChange} modelInfo={modelInfo} deployments={deployments} />
) : undefined
}
>
{children}
</AutoRouterClassifierTabs>
);
};
const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
isVisible,
onCancel,
@ -635,7 +675,14 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
{isComplexityRouterModel ? (
/* Complexity Router Configuration */
<div className="w-full">
<AutoRouterClassifierTabs value={complexityRouterConfig} onChange={setComplexityRouterConfig}>
<EditClassifierTabs
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
modelInfo={modelInfo}
accessToken={accessToken}
userRole={userRole}
isVisible={isVisible}
>
<ComplexityRouterConfig
editingTiers={editingTiers}
onEditingTiersChange={setEditingTiers}
@ -664,7 +711,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
autoRouterCompression={autoRouterCompression}
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
/>
</AutoRouterClassifierTabs>
</EditClassifierTabs>
</div>
) : (
<>

View file

@ -815,6 +815,24 @@ describe("autorouter_presets", () => {
});
describe("getReferencedModelsError", () => {
it.each(["jev", "heuristic"] as const)(
"only checks a removed decision deployment when %s is active",
(classifierType) => {
const config = {
tiers: { SIMPLE: ["solver"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifierType,
classifierLlmConfig: undefined,
decisionDeploymentName: "removed-decision",
semanticMatchingEnabled: false,
embeddingModel: undefined,
};
const availability = groupsOnly(["solver"]);
expect(getReferencedModelsError(config, availability)).toBe(
classifierType === "jev" ? "Model(s) no longer available: removed-decision" : null,
);
},
);
const tiers = { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] };
const available = groupsOnly(["gpt-5-nano"]);
// Both fields are always populated with a model missing from `available`; only the

View file

@ -248,21 +248,27 @@ export const getReferencedModelsError = (
tiers: ComplexityRouterConfigPayload["tiers"];
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
decisionDeploymentName?: string;
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
defaultModel?: string;
},
availability: ModelAvailability,
): string | null => {
const missing = getMissingModels(
{
tiers: params.tiers,
default_model: params.defaultModel,
classifier_llm_config: usesLlmClassifier(params.classifierType) ? params.classifierLlmConfig : undefined,
embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined,
},
availability,
);
if (
params.classifierType === "jev" &&
params.decisionDeploymentName &&
!resolveAvailableModel(params.decisionDeploymentName, availability)
) {
return `Model(s) no longer available: ${params.decisionDeploymentName}`;
}
const references = {
tiers: params.tiers,
default_model: params.defaultModel,
classifier_llm_config: usesLlmClassifier(params.classifierType) ? params.classifierLlmConfig : undefined,
embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined,
};
const missing = getMissingModels(references, availability);
return missing.length > 0 ? `Model(s) no longer available: ${missing.join(", ")}` : null;
};

View file

@ -13658,6 +13658,31 @@ export interface paths {
patch?: never;
trace?: never;
};
"/public/whats_new": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Public Whats New
* @description Return the launches the dashboard Home page shows under What's new.
*
* Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``)
* on the first request, falling back to the list bundled with the package on any failure, so an airgapped
* proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch.
* A restart picks up a newly published list.
*/
get: operations["get_public_whats_new_public_whats_new_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/queue/chat/completions": {
parameters: {
query?: never;
@ -39186,6 +39211,8 @@ export interface components {
* @default true
*/
circuit_breaker_enabled: boolean;
/** Deployment Name */
deployment_name?: string | null;
/**
* Instructions
* @description Replaces the built-in classification instructions
@ -49245,6 +49272,30 @@ export interface components {
type: "web_search" | "web_search_2025_08_26";
user_location?: components["schemas"]["openai__types__responses__web_search_tool_param__UserLocation"] | null;
};
/**
* WhatsNewLaunch
* @description One launch card in the dashboard Home page's What's new section.
*/
WhatsNewLaunch: {
/** Description */
description: string;
/** Href */
href: string;
/** Icon */
icon: string;
/**
* Published On
* Format: date
*/
published_on: string;
/** Title */
title: string;
};
/** WhatsNewResponse */
WhatsNewResponse: {
/** Launches */
launches: components["schemas"]["WhatsNewLaunch"][];
};
/** WorkerRegistryEntry */
WorkerRegistryEntry: {
/** Name */
@ -68630,6 +68681,26 @@ export interface operations {
};
};
};
get_public_whats_new_public_whats_new_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WhatsNewResponse"];
};
};
};
};
async_queue_request_queue_chat_completions_post: {
parameters: {
query?: {