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
4 changes: 4 additions & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,10 @@ def _dev_env_hot_reload_enabled() -> bool:
"LITELLM_ANTHROPIC_BETA_HEADERS_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/anthropic_beta_headers_config.json",
)
autorouter_presets_url: str = os.getenv(
"LITELLM_AUTOROUTER_PRESETS_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/autorouter_presets.json",
)
suppress_debug_info: bool = False
dynamodb_table_name: Optional[str] = None
s3_callback_params: Optional[Dict] = None
Expand Down
85 changes: 84 additions & 1 deletion litellm/proxy/public_endpoints/public_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import asyncio
import json
import os
import re
from collections.abc import Awaitable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from importlib.resources import files
from typing import TYPE_CHECKING, Final, Protocol

from fastapi import APIRouter, HTTPException, Request
from pydantic import TypeAdapter
from typing_extensions import ReadOnly, TypedDict

import litellm
Expand All @@ -28,6 +30,7 @@
)
from litellm.types.proxy.public_endpoints.public_endpoints import (
AgentCreateInfo,
AutoRouterPresetRecord,
ComplexityScorerDefaults,
ProviderCreateInfo,
PublicModelHubInfo,
Expand Down Expand Up @@ -464,6 +467,86 @@ async def get_litellm_blog_posts():
return BlogPostsResponse(posts=posts)


_AUTOROUTER_PRESETS_ADAPTER: Final = TypeAdapter(dict[str, AutoRouterPresetRecord])


def _load_bundled_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]:
raw: Final = json.loads(
files("litellm.proxy.public_endpoints").joinpath("autorouter_presets.json").read_text(encoding="utf-8")
)
return _AUTOROUTER_PRESETS_ADAPTER.validate_python(raw)


async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
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())
if not presets:
raise ValueError("remote auto-router preset catalog is empty")
return presets


async def _resolve_autorouter_presets(
url: str,
fetch: Callable[[str], Awaitable[Mapping[str, AutoRouterPresetRecord]]],
) -> Mapping[str, AutoRouterPresetRecord]:
if os.getenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "").lower() == "true":
return _load_bundled_autorouter_presets()
try:
return await fetch(url)
except Exception as e:
verbose_logger.warning(
"LiteLLM: failed to fetch auto-router presets from %s: %s. Serving the bundled catalog for the life of this process.",
url,
str(e),
)
return _load_bundled_autorouter_presets()


class _AutoRouterPresetsCache:
presets: Mapping[str, AutoRouterPresetRecord] | None = None
lock: asyncio.Lock | None = None


async def get_autorouter_presets(
url: str,
fetch: Callable[[str], Awaitable[Mapping[str, AutoRouterPresetRecord]]] = _fetch_remote_autorouter_presets,
) -> Mapping[str, AutoRouterPresetRecord]:
cached: Final = _AutoRouterPresetsCache.presets
if cached is not None:
return cached
if _AutoRouterPresetsCache.lock is None:
_AutoRouterPresetsCache.lock = asyncio.Lock()
async with _AutoRouterPresetsCache.lock:
held: Final = _AutoRouterPresetsCache.presets
if held is not None:
return held
resolved: Final = await _resolve_autorouter_presets(url=url, fetch=fetch)
_AutoRouterPresetsCache.presets = resolved
return resolved


@router.get(
"/public/autorouter_presets",
tags=["public", "auto router"], # mutable-ok: FastAPI route tags take a list
response_model=dict[str, AutoRouterPresetRecord],
)
async def get_public_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]:
"""
Return the auto-router preset catalog the dashboard's template picker renders.

Resolved once per process, like the model cost map: fetched from ``litellm.autorouter_presets_url``
(override with ``LITELLM_AUTOROUTER_PRESETS_URL``) on the first request, falling back to the
catalog bundled with the package on any failure. Set ``LITELLM_LOCAL_AUTOROUTER_PRESETS=True``
to serve the bundled catalog only. A restart picks up a newly published catalog.
"""
return await get_autorouter_presets(url=litellm.autorouter_presets_url)


@router.get(
"/public/endpoints",
tags=["public"],
Expand Down
42 changes: 40 additions & 2 deletions litellm/types/proxy/public_endpoints/public_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import Any, Literal

from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict


class PublicModelHubInfo(BaseModel):
Expand Down Expand Up @@ -73,6 +73,44 @@ class SupportedEndpointsResponse(BaseModel):
endpoints: list[SupportedEndpoint]


class AutoRouterPresetTiers(BaseModel):
"""Exactly the four built-in tiers the dashboard's preset prefill can apply.

extra="forbid" on purpose: a tier name this dashboard cannot apply would grey out or crash the
picker, so such a catalog is rejected wholesale and the bundled one serves instead.
"""

model_config = ConfigDict(extra="forbid")

SIMPLE: Sequence[str]
MEDIUM: Sequence[str]
COMPLEX: Sequence[str]
REASONING: Sequence[str]


class AutoRouterPresetConfig(BaseModel):
"""The complexity_router_config a preset prefills.

Only tiers is validated, because every dashboard consumer dereferences it; everything else
passes through verbatim with unknown fields kept (extra="allow"), so a catalog published after
this proxy shipped still serves its new fields intact.
"""

model_config = ConfigDict(extra="allow")

tiers: AutoRouterPresetTiers

Comment thread
cursor[bot] marked this conversation as resolved.

class AutoRouterPresetRecord(BaseModel):
"""One auto-router preset as served to the dashboard's template picker."""

model_config = ConfigDict(extra="allow")

label: str
description: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Nested preset shape remains unchecked

When a remote or overridden catalog contains an envelope-valid preset with missing or malformed tiers, this mapping accepts it and the dashboard later calls Object.values or models.map on the invalid values while rendering the dialog. One malformed entry therefore crashes the Add Auto Router form instead of being rejected so the bundled catalog can be served.

Knowledge Base Used: Dashboard and enterprise UI

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5af5e21: tiers is now validated per preset, so a malformed catalog rejects and the bundled one serves; pinned by adapter tests

complexity_router_config: AutoRouterPresetConfig


class ComplexityScorerDefaults(BaseModel):
"""The complexity router's shipped heuristic scorer defaults.

Expand Down
Loading
Loading