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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,7 @@ class LiteLLMRoutes(enum.Enum):
# proxy admin, or team admin naming their own team via team_id
"/auto_router/test_routing",
"/auto_router/validate_complexity_router_config",
"/auto_router/availability",
# Per-session auto-router read - the endpoint scopes the row to the caller's own key hash
"/auto_router/session",
"/cost/predict-cache",
Expand Down
50 changes: 50 additions & 0 deletions litellm/proxy/management_endpoints/auto_router_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
)
from litellm.types.management_endpoints.auto_router_endpoints import (
SHADOW_EVAL_TURN_VALVE,
AutoRouterAvailabilityRequest,
AutoRouterAvailabilityResponse,
AutoRouterBenchmarkGroup,
AutoRouterBenchmarksResponse,
AutoRouterBenchmarkTotals,
Expand Down Expand Up @@ -391,6 +393,54 @@ async def validate_complexity_router_config(
return ComplexityRouterConfigValidationResponse(valid=error is None, error=error)


@router.post(
"/auto_router/availability",
tags=["model management"], # mutable-ok: FastAPI requires a list
response_model=AutoRouterAvailabilityResponse,
)
async def get_auto_router_availability(
data: AutoRouterAvailabilityRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> AutoRouterAvailabilityResponse:
from litellm.proxy.management_helpers.auto_router_availability import auto_router_availability
from litellm.proxy.proxy_server import (
_license_check, # pyright: ignore[reportPrivateUsage] # same entitlement owner as the model write gate
heuristic_v1_tuning_baselines,
llm_router,
proxy_config,
)

member_team: Final = await _authorize_router_dry_run(user_api_key_dict, data.team_id)
rows: Final = proxy_config.auto_router_db_catalog
if rows is None or llm_router is None:
raise HTTPException(status_code=503, detail="Auto-router availability is unavailable")
saved: Final = next((row for row in rows if row.model_id == data.saved_model_id), None)
if data.saved_model_id is not None:
if saved is None:
raise HTTPException(status_code=404, detail="Saved auto router is unavailable")
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN and (
saved.team_id != data.team_id or (member_team is not None and saved.created_by != user_api_key_dict.user_id)
):
raise HTTPException(status_code=403, detail="Cannot check another user's auto router")
existing: Final = saved.deployment if saved is not None else None
others: Final = tuple(row.deployment for row in rows if row is not saved) + tuple(llm_router.config_deployments())
candidate: Final = MappingProxyType(
{
"litellm_params": MappingProxyType(
{"model": "auto_router/complexity_router", "complexity_router_config": data.complexity_router_config}
),
"model_info": MappingProxyType({"id": data.saved_model_id or "availability-new-router", "db_model": True}),
}
)
return auto_router_availability(
others=others,
existing=existing,
candidate=candidate,
baselines=heuristic_v1_tuning_baselines,
limit=_license_check.auto_router_capability_limit(),
)


async def _resolve_saved_routing_test(
data: AutoRouterRoutingTestRequest,
user_api_key_dict: UserAPIKeyAuth,
Expand Down
13 changes: 8 additions & 5 deletions litellm/proxy/management_endpoints/model_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1782,10 +1782,11 @@ async def delete_team_models(
# Under MODEL_RECONCILE_LOCK, for the same reason as delete_model: the rows are
# gone, but a reconcile holding a pre-delete snapshot would upsert these ids back
# onto this pod. The lock orders the eviction after any in-flight reconcile.
if llm_router is not None:
from litellm.proxy.proxy_server import MODEL_RECONCILE_LOCK
from litellm.proxy.proxy_server import MODEL_RECONCILE_LOCK, proxy_config

async with MODEL_RECONCILE_LOCK:
async with MODEL_RECONCILE_LOCK:
proxy_config.remove_auto_router_catalog_entries(frozenset(deleted_model_ids))
if llm_router is not None:
for model_id in deleted_model_ids:
llm_router.delete_deployment(id=model_id)

Expand Down Expand Up @@ -2194,6 +2195,7 @@ async def delete_model(
llm_router,
premium_user,
prisma_client,
proxy_config,
proxy_logging_obj,
store_model_in_db,
user_api_key_cache,
Expand Down Expand Up @@ -2245,8 +2247,9 @@ async def delete_model(
# this pod serving a model the database no longer has, until the next
# reconcile. Taking the lock orders this eviction after any such in-flight
# reconcile's re-add, so the eviction is the last word.
if llm_router is not None:
async with MODEL_RECONCILE_LOCK:
async with MODEL_RECONCILE_LOCK:
proxy_config.remove_auto_router_catalog_entries(frozenset({model_info.id}))
if llm_router is not None:
llm_router.delete_deployment(id=model_info.id)

# Runs after the row delete so the sibling check sees post-delete state.
Expand Down
148 changes: 148 additions & 0 deletions litellm/proxy/management_helpers/auto_router_availability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final

from pydantic import BaseModel, Json, TypeAdapter, ValidationError

from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.router_utils.auto_router_model_naming import (
GATED_AUTO_ROUTER_CAPABILITIES,
capability_limit_violation,
classify_strategy_router_model,
count_capability_routers,
gated_capability_of,
)
from litellm.router_utils.auto_router_tuning_baseline import (
is_mutable_tuned_candidate,
mutable_tuned_identities,
tuning_quota_violation,
)
from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterAllowance,
AutoRouterAvailabilityResponse,
)


class _CatalogModelInfo(BaseModel):
team_id: str | None = None


class _CatalogSource(BaseModel):
model_id: str
created_by: str | None = None
litellm_params: Json[dict[str, object]] | dict[str, object]
model_info: Json[_CatalogModelInfo] | _CatalogModelInfo | None = None


@dataclass(frozen=True, slots=True)
class AutoRouterCatalogEntry:
model_id: str
team_id: str | None
created_by: str | None
deployment: Mapping[str, object]


def _catalog_field(value: object, key: str) -> object:
if not isinstance(value, str):
return deepcopy(value)
return decrypt_value_helper(value, key=key, exception_type="debug", return_original_value=True)


def build_auto_router_catalog(rows: Sequence[object]) -> tuple[AutoRouterCatalogEntry, ...] | None:
try:
sources: Final = TypeAdapter(tuple[_CatalogSource, ...]).validate_python(rows, from_attributes=True)
except ValidationError:
return None
return tuple(
AutoRouterCatalogEntry(
model_id=row.model_id,
team_id=row.model_info.team_id if row.model_info is not None else None,
created_by=row.created_by,
deployment=MappingProxyType(
{
"litellm_params": MappingProxyType(
{
"model": model,
"complexity_router_config": _catalog_field(
row.litellm_params.get("complexity_router_config"), "complexity_router_config"
),
}
),
"model_info": MappingProxyType({"id": row.model_id, "db_model": True}),
}
),
)
for row in sources
if isinstance(model := _catalog_field(row.litellm_params.get("model"), "model"), str)
and classify_strategy_router_model(model) == "complexity"
)


def auto_router_availability(
*,
others: Sequence[Mapping[str, object]],
existing: Mapping[str, object] | None,
candidate: Mapping[str, object],
baselines: Mapping[str, str] | None,
limit: int | None,
) -> AutoRouterAvailabilityResponse:
existing_params: Final = None if existing is None else existing.get("litellm_params")
candidate_params: Final = candidate.get("litellm_params")
owned: Final = gated_capability_of(existing_params) if isinstance(existing_params, Mapping) else None
claimed: Final = gated_capability_of(candidate_params) if isinstance(candidate_params, Mapping) else None
counts: Final = tuple(
(capability, count_capability_routers(others, capability=capability))
for capability in GATED_AUTO_ROUTER_CAPABILITIES
)
tuned_count: Final = len(mutable_tuned_identities(others, baselines)) if baselines is not None else 0
allowances: Final = tuple(
AutoRouterAllowance(
key=capability.key,
limit=limit,
remaining=None if limit is None else max(0, limit - held),
used_by_this_router=owned is capability,
)
for capability, held in counts
)
capability_error: Final = next(
(
capability_limit_violation(capability=capability, held=held + 1, limit=limit)
for capability, held in counts
if capability is claimed
),
None,
)
tuning_error: Final = (
tuning_quota_violation(candidate=candidate, others=others, baselines=baselines, limit=limit)
if baselines is not None
else None
)
capability_labels: Final = {
"heuristic_v2": "Heuristic v2",
"capability": "Capability",
"llm_v2": "Fuse v2",
"tier_or_classifier_prompt": "Custom tiers or classifier instructions",
}
return AutoRouterAvailabilityResponse(
allowances=(
*allowances,
AutoRouterAllowance(
key="heuristic_tuning",
limit=limit,
remaining=None if limit is None or baselines is None else max(0, limit - tuned_count),
available=limit is None or baselines is not None,
used_by_this_router=bool(
existing is not None and baselines is not None and is_mutable_tuned_candidate(existing, baselines)
),
),
),
error=(
f"{capability_labels[claimed.key]} has no available allowance. Choose another option or free an existing allowance."
if capability_error is not None and claimed is not None
else "These scoring rules need an available Rule-based tuning allowance. Check the weights, thresholds, keywords, and custom dimensions in Advanced settings. Model choices do not use this allowance."
if tuning_error is not None
else None
),
)
9 changes: 9 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
strip_callback_config,
)
from litellm.proxy.common_utils.realtime_utils import _realtime_request_body
from litellm.proxy.management_helpers.auto_router_availability import AutoRouterCatalogEntry, build_auto_router_catalog
from litellm.router_utils.access_windows import access_windows_config_error
from litellm.router_utils.add_retry_fallback_headers import (
get_fallback_errors_from_headers,
Expand Down Expand Up @@ -5068,6 +5069,7 @@ class ProxyConfig:

def __init__(self) -> None:
self.config: Mapping[str, object] = MappingProxyType({})
self.auto_router_db_catalog: tuple[AutoRouterCatalogEntry, ...] | None = None
self._last_semantic_filter_config: dict[str, object] | None = None
self._last_websearch_interception_config: dict[str, object] | None = None
self._last_hashicorp_vault_config: dict[str, object] | None = None
Expand Down Expand Up @@ -7691,6 +7693,12 @@ def _apply_litellm_settings_db_values(self, db_values: Mapping[str, SettingsJson
def _should_load_db_object(self, object_type: str | SupportedDBObjectType) -> bool:
return should_load_db_object(object_type=object_type)

def remove_auto_router_catalog_entries(self, model_ids: frozenset[str]) -> None:
if self.auto_router_db_catalog is not None:
self.auto_router_db_catalog = tuple(
row for row in self.auto_router_db_catalog if row.model_id not in model_ids
)

async def _get_models_from_db(self, prisma_client: PrismaClient) -> Sequence[_ProxyModelRow] | None:
"""
Fetch all model deployments from the DB.
Expand All @@ -7711,6 +7719,7 @@ async def _get_models_from_db(self, prisma_client: PrismaClient) -> Sequence[_Pr
new_models: Final[Sequence[_ProxyModelRow]] = await ModelRepository(
WriterPinnedClient(prisma_client.db)
).table.find_many()
self.auto_router_db_catalog = build_auto_router_catalog(new_models)
Comment thread
moe-berri marked this conversation as resolved.
return new_models
except Exception as e:
verbose_proxy_logger.exception(
Expand Down
36 changes: 18 additions & 18 deletions litellm/proxy/public_endpoints/autorouter_presets.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"1m_context": {
"label": "1M Context",
"description": "Routes across models with 1M-token context windows: Luna for simple queries, Terra for medium, Sol for complex, Opus 5 at high thinking for reasoning.",
"description": "Routes across models with 1M-token context windows: GPT-6 Luna for simple queries, GPT-5.6 Terra for medium, GPT-6 Sol for complex, Opus 5.5 at high thinking for reasoning.",
"complexity_router_config": {
"tiers": {
"SIMPLE": ["gpt-5.6-luna"],
"SIMPLE": ["gpt-6-luna"],
"MEDIUM": ["gpt-5.6-terra"],
"COMPLEX": ["gpt-5.6-sol"],
"REASONING": ["claude-opus-5"]
"COMPLEX": ["gpt-6-sol"],
"REASONING": ["claude-opus-5-5"]
},
"tier_model_configs": {
"REASONING": [
{
"model_name": "claude-opus-5",
"model_name": "claude-opus-5-5",
"litellm_params": { "reasoning_effort": "high" }
}
]
Expand All @@ -28,12 +28,12 @@
},
"anthropic_family": {
"label": "Anthropic Family",
"description": "Routes across the Claude model family: Haiku for simple queries, Sonnet for medium, Opus for complex, Fable 5.1 at high thinking for reasoning.",
"description": "Routes across the Claude model family: Haiku for simple queries, Sonnet for medium, Opus 5.5 for complex, Fable 5.1 at high thinking for reasoning.",
"complexity_router_config": {
"tiers": {
"SIMPLE": ["claude-haiku-4-5"],
"MEDIUM": ["claude-sonnet-5"],
"COMPLEX": ["claude-opus-5"],
"COMPLEX": ["claude-opus-5-5"],
"REASONING": ["claude-fable-5-1"]
},
"tier_model_configs": {
Expand All @@ -55,12 +55,12 @@
},
"gemini_family": {
"label": "Gemini Family",
"description": "Routes across the Gemini model family: Flash Lite 2.5 for simple queries, Flash Lite 3.1 for medium, Flash 3.7 for complex, Pro 3.1 for reasoning-heavy requests.",
"description": "Routes across the Gemini model family: Flash Lite 3.5 for simple queries, Flash 3.8 for medium and complex queries, Pro 3.1 for reasoning-heavy requests.",
"complexity_router_config": {
"tiers": {
"SIMPLE": ["gemini-2.5-flash-lite"],
"MEDIUM": ["gemini-3.1-flash-lite"],
"COMPLEX": ["gemini-3.7-flash"],
"SIMPLE": ["gemini-3.5-flash-lite"],
"MEDIUM": ["gemini-3.8-flash"],
"COMPLEX": ["gemini-3.8-flash"],
"REASONING": ["gemini-3.1-pro-preview"]
},
"classifier_type": "heuristic",
Expand All @@ -74,18 +74,18 @@
},
"lite": {
"label": "Lite",
"description": "Cost-optimized routing across providers: DeepSeek V4 Flash for simple queries, Muse Spark 1.2 at xhigh for medium, Kimi K3 at max for complex, Claude Opus 5 for reasoning. An LLM classifier with the agentic rubric assigns tiers.",
"description": "Cost-optimized routing across providers: DeepSeek V4 Flash for simple queries, Muse Spark 1.3 at xhigh for medium, Kimi K3 at max for complex, Claude Opus 5.5 for reasoning. An LLM classifier with the agentic rubric assigns tiers.",
"complexity_router_config": {
"tiers": {
"SIMPLE": ["deepseek-v4-flash"],
"MEDIUM": ["muse-spark-1.2"],
"MEDIUM": ["muse-spark-1.3"],
"COMPLEX": ["kimi-k3"],
"REASONING": ["claude-opus-5"]
"REASONING": ["claude-opus-5-5"]
},
"tier_model_configs": {
"MEDIUM": [
{
"model_name": "muse-spark-1.2",
"model_name": "muse-spark-1.3",
"litellm_params": { "reasoning_effort": "xhigh" }
}
],
Expand Down Expand Up @@ -113,12 +113,12 @@
},
"openai_family": {
"label": "OpenAI Family",
"description": "Routes across the GPT model family: Luna for simple queries, Terra for medium, Sol for complex, Astra at xhigh thinking for reasoning.",
"description": "Routes across the GPT model family: GPT-6 Luna for simple queries, GPT-5.6 Terra for medium, GPT-6 Sol for complex, GPT-6 Astra at xhigh thinking for reasoning.",
"complexity_router_config": {
"tiers": {
"SIMPLE": ["gpt-5.6-luna"],
"SIMPLE": ["gpt-6-luna"],
"MEDIUM": ["gpt-5.6-terra"],
"COMPLEX": ["gpt-5.6-sol"],
"COMPLEX": ["gpt-6-sol"],
"REASONING": ["gpt-6-astra"]
},
"tier_model_configs": {
Expand Down
Loading
Loading